public/list/New-UiList.ps1

function New-UiList {
    <#
    .SYNOPSIS
        Themed listbox. Optional filter box and select-all/none buttons.
    .DESCRIPTION
        Builds a themed ListBox with opt-in toolbar controls: a real-time filter box,
        select-all/none buttons, a manual add button. Supports both static arrays and
        dynamic ObservableCollection binding. Use -DisplayFormat for object lists where
        each item is a hashtable with named properties.
    .PARAMETER Variable
        Variable name for the list.
    .PARAMETER Items
        Array of static items to display. Mutually exclusive with ItemsSource.
    .PARAMETER ItemsSource
        A collection (e.g., ObservableCollection) to bind as the list's data source.
        Use this for dynamic collections that update at runtime.
    .PARAMETER DisplayFormat
        Format string for displaying objects. Use property names in braces.
        Example: "{Username} ({AccountType})" shows "jsmith (Admin)".
        When specified, Add-UiListItem automatically generates display text from hashtables.
    .PARAMETER MultiSelect
        Allow multiple selection.
    .PARAMETER Filterable
        Adds a filter textbox above the list. As the user types, items are filtered
        in real-time. Includes a clear button (X) that appears when text is entered.
    .PARAMETER SelectionControls
        Adds "All" and "None" buttons for quick select/deselect operations.
        Most useful with -MultiSelect. Buttons appear in the filter toolbar.
    .PARAMETER AllowAdd
        Adds a "+" button to the toolbar that opens an input dialog for manually
        adding items to the list. Useful when items can't be auto-discovered.
    .PARAMETER AddPrompt
        Custom prompt text for the add item dialog. Defaults to "Enter item to add:".
    .PARAMETER Height
        Fixed height in pixels. Defaults to 150. Ignored when -Fill is set.
    .PARAMETER Fill
        Grow to the rest of the window's vertical view port instead of the fixed -Height.
        List resizes with the window. Use when the list is the dominant content in the view.
    .PARAMETER FullWidth
        Stretches the list to fill available width.
    .PARAMETER EnabledWhen
        Variable name that controls whether the list is enabled. Truthy value = enabled.
    .PARAMETER WPFProperties
        Hashtable of additional WPF properties to set on the control.
    .EXAMPLE
        New-UiList -Variable "list" -Items @('A','B','C')
    .EXAMPLE
        # Filterable multi-select list with selection controls
        New-UiList -Variable "servers" -MultiSelect -Filterable -SelectionControls
    .EXAMPLE
        # List with manual add button for items that can't be auto-discovered
        New-UiList -Variable "uags" -MultiSelect -AllowAdd -AddPrompt "Enter UAG hostname:"
    .EXAMPLE
        # Object list with auto-formatted display
        New-UiList -Variable "queue" -DisplayFormat "{Username} ({AccountType})"
        # Then just pass hashtables - display text is automatic:
        Add-UiListItem 'queue' @{ Username = 'jsmith'; FullName = 'John'; AccountType = 'Admin' }
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Variable,

        [Parameter()]
        [string[]]$Items,

        [Parameter()]
        [System.Collections.IEnumerable]$ItemsSource,

        [Parameter()]
        [string]$DisplayFormat,

        [switch]$MultiSelect,

        [switch]$Filterable,

        [switch]$SelectionControls,

        [switch]$AllowAdd,

        [string]$AddPrompt = 'Enter item to add:',

        [int]$Height = 150,

        [switch]$Fill,

        [switch]$FullWidth,

        [Parameter()]
        [object]$EnabledWhen,

        [Parameter()]
        [hashtable]$WPFProperties
    )

    if ($Items -and $ItemsSource) {
        throw "New-UiList cannot use both -Items and -ItemsSource. Choose one."
    }

    $session = Assert-UiSession -CallerName 'New-UiList'
    Write-Debug "Creating list '$Variable' (MultiSelect=$MultiSelect, Height=$Height, Filterable=$Filterable)"
    $colors  = Get-ThemeColors
    $parent  = $session.CurrentParent

    $needsToolbar = $Filterable -or $SelectionControls -or $AllowAdd

    $listBox = [System.Windows.Controls.ListBox]::new()
    $listBox.Margin = [System.Windows.Thickness]::new(0)
    $listBox.SelectionMode = if ($MultiSelect) { 'Extended' } else { 'Single' }

    # Raise scroll events to the parent ScrollViewer so the list doesn't swallow them.
    # Skipped under -Fill: a filled list already claims the viewport, so raising the wheel again would leave nothing to scroll and kill it entirely. The list's own scrollbar owns it.
    if (!$Fill) {
        $listBox.Add_PreviewMouseWheel({
            param($sender, $eventArgs)
            if (!$eventArgs.Handled) {
                $eventArgs.Handled = $true
                $newEvent = [System.Windows.Input.MouseWheelEventArgs]::new($eventArgs.MouseDevice, $eventArgs.Timestamp, $eventArgs.Delta)
                $newEvent.RoutedEvent = [System.Windows.UIElement]::MouseWheelEvent
                $newEvent.Source = $sender
                $parentElement = $sender.Parent -as [System.Windows.UIElement]
                if ($parentElement) { $parentElement.RaiseEvent($newEvent) }
            }
        })
    }

    if ($DisplayFormat) {
        Write-Debug "Registering DisplayFormat: $DisplayFormat"
        $listBox.DisplayMemberPath = '_DisplayText'
        $session.RegisterListDisplayFormat($Variable, $DisplayFormat)
    }

    Set-ListBoxStyle -ListBox $listBox

    # Build the data source before filter setup - filtering needs a collection behind a CollectionView.
    $sourceCollection = $null
    if ($null -ne $ItemsSource) {
        Write-Debug "Binding external ItemsSource collection"
        $sourceCollection = $ItemsSource
        
        # AsyncObservableCollection - refresh its Dispatcher to the current thread.
        if ($ItemsSource.GetType().Name -like 'AsyncObservableCollection*') {
            try { $ItemsSource.UpdateDispatcher() }
            catch { Write-Debug "Failed to update dispatcher: $_" }
        }

        # Register for Add-UiListItem access
        if ($ItemsSource -is [System.Collections.IList]) {
            $session.RegisterListCollection($Variable, $ItemsSource)
        }
        elseif ($ItemsSource | Get-Member -Name 'Add' -MemberType Method) {
            $session.RegisterListCollection($Variable, $ItemsSource)
        }
    }
    elseif ($Items) {
        # Convert static items to ObservableCollection for filtering support
        Write-Debug "Converting $($Items.Count) static items to collection"
        $sourceCollection = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
        foreach ($item in $Items) { [void]$sourceCollection.Add($item) }
        
        # Register for Add-UiListItem access
        $session.RegisterListCollection($Variable, $sourceCollection)
    }
    else {
        # Auto-create collection for dynamic use
        Write-Debug "Creating auto AsyncObservableCollection"
        $sourceCollection = [PsUi.AsyncObservableCollection[object]]::new()
        $session.RegisterListCollection($Variable, $sourceCollection)
    }

    # Set up ItemsSource with CollectionView for filtering
    if ($null -ne $sourceCollection) {
        $collectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($sourceCollection)
        $listBox.ItemsSource = $collectionView
    }

    # Container: DockPanel with toolbar, or the bare listBox on its own.
    if ($needsToolbar) {
        $container = [System.Windows.Controls.DockPanel]@{
            Margin = [System.Windows.Thickness]::new(4, 4, 4, 8)
        }

        # Create toolbar row: [Icon] [Filter textbox*] [All btn?] [None btn?]
        $toolbar = [System.Windows.Controls.Grid]@{
            Margin = [System.Windows.Thickness]::new(0, 0, 0, 4)
        }
        [System.Windows.Controls.DockPanel]::SetDock($toolbar, 'Top')

        $colIndex = 0

        # Icon column (auto) - only if filterable
        if ($Filterable) {
            $iconCol = [System.Windows.Controls.ColumnDefinition]::new()
            $iconCol.Width = [System.Windows.GridLength]::Auto
            [void]$toolbar.ColumnDefinitions.Add($iconCol)
            $colIndex++
        }

        # Filter textbox column (stretch)
        $filterCol = [System.Windows.Controls.ColumnDefinition]::new()
        $filterCol.Width = [System.Windows.GridLength]::new(1, [System.Windows.GridUnitType]::Star)
        [void]$toolbar.ColumnDefinitions.Add($filterCol)
        $filterColIndex = $colIndex; $colIndex++

        if ($SelectionControls) {
            # Selection count column (auto) - only if MultiSelect too
            if ($MultiSelect) {
                $countCol = [System.Windows.Controls.ColumnDefinition]::new()
                $countCol.Width = [System.Windows.GridLength]::Auto
                [void]$toolbar.ColumnDefinitions.Add($countCol)
                $countColIndex = $colIndex; $colIndex++
            }

            # All button column
            $allCol = [System.Windows.Controls.ColumnDefinition]::new()
            $allCol.Width = [System.Windows.GridLength]::Auto
            [void]$toolbar.ColumnDefinitions.Add($allCol)
            $allColIndex = $colIndex; $colIndex++

            # None button column
            $noneCol = [System.Windows.Controls.ColumnDefinition]::new()
            $noneCol.Width = [System.Windows.GridLength]::Auto
            [void]$toolbar.ColumnDefinitions.Add($noneCol)
            $noneColIndex = $colIndex; $colIndex++
        }

        # Add button column (auto) - if AllowAdd
        if ($AllowAdd) {
            $addCol = [System.Windows.Controls.ColumnDefinition]::new()
            $addCol.Width = [System.Windows.GridLength]::Auto
            [void]$toolbar.ColumnDefinitions.Add($addCol)
            $addColIndex = $colIndex; $colIndex++
        }

        # Create filter textbox in its own container (or placeholder if only selection controls)
        $filterBox = $null
        if ($Filterable) {
            # Search icon OUTSIDE the textbox (to the left)
            $searchIcon = [System.Windows.Controls.TextBlock]@{
                Text                = [PsUi.ModuleContext]::GetIcon('Search')
                FontFamily          = [PsUi.ModuleContext]::ActiveIconFontFamily
                FontSize            = 14
                Foreground          = ConvertTo-UiBrush $colors.SecondaryText
                VerticalAlignment   = 'Center'
                Margin              = [System.Windows.Thickness]::new(0, 0, 6, 0)
                Tag                 = 'SecondaryTextBrush'
            }
            [PsUi.ThemeEngine]::RegisterElement($searchIcon)
            [System.Windows.Controls.Grid]::SetColumn($searchIcon, 0)
            [void]$toolbar.Children.Add($searchIcon)

            $filterContainer = [System.Windows.Controls.Grid]@{
                VerticalAlignment = 'Center'
            }
            [System.Windows.Controls.Grid]::SetColumn($filterContainer, $filterColIndex)

            $filterBox = [System.Windows.Controls.TextBox]@{
                Height   = 26
                Padding  = [System.Windows.Thickness]::new(4, 0, 20, 0)
                FontSize = 13
                ToolTip  = 'Type to filter items'
            }
            Set-TextBoxStyle -TextBox $filterBox
            [void]$filterContainer.Children.Add($filterBox)

            # Clear button overlay (right side) - uses $this.Tag pattern like datagrid
            $clearBtn = [System.Windows.Controls.Button]@{
                Content             = [PsUi.ModuleContext]::GetIcon('Cancel')
                FontFamily          = [PsUi.ModuleContext]::ActiveIconFontFamily
                FontSize            = 10
                Width               = 16
                Height              = 16
                Padding             = [System.Windows.Thickness]::new(0)
                Margin              = [System.Windows.Thickness]::new(0, 0, 5, 0)
                HorizontalAlignment = 'Right'
                VerticalAlignment   = 'Center'
                Background          = [System.Windows.Media.Brushes]::Transparent
                BorderThickness     = [System.Windows.Thickness]::new(0)
                Cursor              = [System.Windows.Input.Cursors]::Hand
                Visibility          = 'Collapsed'
                ToolTip             = 'Clear filter'
                Tag                 = $filterBox
            }
            $clearBtn.SetResourceReference([System.Windows.Controls.Button]::ForegroundProperty, 'SecondaryTextBrush')
            $clearBtn.Add_Click({ $this.Tag.Text = ''; $this.Tag.Focus() }.GetNewClosure())
            [void]$filterContainer.Children.Add($clearBtn)

            # Store refs in filterBox.Tag for TextChanged handler (including timer slot)
            # SourceCollection stores unfiltered items for collection-based filtering
            $filterBox.Tag = @{
                ClearButton      = $clearBtn
                ListView         = $listBox
                Timer            = $null
                SourceCollection = $sourceCollection
            }

            [void]$toolbar.Children.Add($filterContainer)
        }
        else {
            # No filter - just an empty space to push buttons right
            $spacer = [System.Windows.Controls.Border]::new()
            [System.Windows.Controls.Grid]::SetColumn($spacer, $filterColIndex)
            [void]$toolbar.Children.Add($spacer)
        }

        $countLabel = $null
        if ($SelectionControls) {
            # Selection count label (only for MultiSelect)
            if ($MultiSelect) {
                $countLabel = [System.Windows.Controls.TextBlock]@{
                    Text                = '(0/0)'
                    FontSize            = 11
                    Foreground          = ConvertTo-UiBrush $colors.SecondaryText
                    VerticalAlignment   = 'Center'
                    TextAlignment       = 'Right'
                    Margin              = [System.Windows.Thickness]::new(8, 0, 4, 0)
                    MinWidth            = 55
                    Tag                 = 'SecondaryTextBrush'
                }
                [PsUi.ThemeEngine]::RegisterElement($countLabel)
                [System.Windows.Controls.Grid]::SetColumn($countLabel, $countColIndex)
                [void]$toolbar.Children.Add($countLabel)
            }

            # "All" button
            $allBtn = [System.Windows.Controls.Button]@{
                Content = 'All'
                Width   = 36
                Height  = 24
                Margin  = [System.Windows.Thickness]::new(4, 0, 0, 0)
                Padding = [System.Windows.Thickness]::new(6, 2, 6, 2)
                ToolTip = 'Select all items'
                Cursor  = [System.Windows.Input.Cursors]::Hand
            }
            Set-ButtonStyle -Button $allBtn
            [System.Windows.Controls.Grid]::SetColumn($allBtn, $allColIndex)
            [void]$toolbar.Children.Add($allBtn)

            # "None" button
            $noneBtn = [System.Windows.Controls.Button]@{
                Content = 'None'
                Width   = 44
                Height  = 24
                Margin  = [System.Windows.Thickness]::new(4, 0, 0, 0)
                Padding = [System.Windows.Thickness]::new(6, 2, 6, 2)
                ToolTip = 'Clear selection'
                Cursor  = [System.Windows.Input.Cursors]::Hand
            }
            Set-ButtonStyle -Button $noneBtn
            [System.Windows.Controls.Grid]::SetColumn($noneBtn, $noneColIndex)
            [void]$toolbar.Children.Add($noneBtn)

            $selState = @{ ListView = $listBox }
            $allBtn.Add_Click({
                $selState.ListView.SelectAll()
            }.GetNewClosure())
            $noneBtn.Add_Click({
                $selState.ListView.UnselectAll()
            }.GetNewClosure())
        }

        if ($AllowAdd) {
            $addBtn = [System.Windows.Controls.Button]@{
                Content    = [PsUi.ModuleContext]::GetIcon('Add')
                FontFamily = [PsUi.ModuleContext]::ActiveIconFontFamily
                FontSize   = 12
                Width      = 24
                Height     = 24
                Margin     = [System.Windows.Thickness]::new(4, 0, 0, 0)
                Padding    = [System.Windows.Thickness]::new(0)
                ToolTip    = 'Add item'
                Cursor     = [System.Windows.Input.Cursors]::Hand
            }
            Set-ButtonStyle -Button $addBtn
            [System.Windows.Controls.Grid]::SetColumn($addBtn, $addColIndex)
            [void]$toolbar.Children.Add($addBtn)

            # Hook the add button, it shows input dialog and pushes into the collection
            $addState = @{
                Collection  = $sourceCollection
                PromptText  = $AddPrompt
                CountLabel  = $countLabel
                ListView    = $listBox
            }
            $addBtn.Add_Click({
                $result = Show-UiInputDialog -Title 'Add Item' -Prompt $addState.PromptText
                if (![string]::IsNullOrWhiteSpace($result)) {
                    [void]$addState.Collection.Add($result)
                    
                    # Auto-select the newly added item (keeps existing selections)
                    $addState.ListView.SelectedItems.Add($result)
                    
                    if ($addState.CountLabel) {
                        $selected = $addState.ListView.SelectedItems.Count
                        $total    = $addState.ListView.Items.Count
                        $addState.CountLabel.Text = "($selected/$total)"
                    }
                }
            }.GetNewClosure())
        }

        [void]$container.Children.Add($toolbar)

        if (!$Fill) { $listBox.Height = $Height }
        [void]$container.Children.Add($listBox)

        if ($countLabel) {
            $listBox.Tag = @{ CountLabel = $countLabel }

            # Update count on selection change
            $listBox.Add_SelectionChanged({
                $label     = $this.Tag.CountLabel
                $selected  = $this.SelectedItems.Count
                $total     = $this.Items.Count
                $label.Text = "($selected/$total)"
            }.GetNewClosure())

            # Set initial count after window loads
            $listBox.Add_Loaded({
                $label    = $this.Tag.CountLabel
                $selected = $this.SelectedItems.Count
                $total    = $this.Items.Count
                $label.Text = "($selected/$total)"
            }.GetNewClosure())
        }

        if ($Filterable -and $filterBox) {
            $filterBox.Add_TextChanged({
                # $this is the TextBox that fired the event
                $textBox    = $this
                $tagData    = $textBox.Tag
                $clearBtn   = $tagData.ClearButton
                $targetList = $tagData.ListView

                if ($clearBtn) {
                    $clearBtn.Visibility = if ([string]::IsNullOrEmpty($textBox.Text)) { 'Collapsed' } else { 'Visible' }
                }

                # Debounce filter updates - timer stored in Tag to avoid collision between lists
                if ($tagData.Timer) {
                    $tagData.Timer.Stop()
                    $tagData.Timer = $null
                }

                $timer = [System.Windows.Threading.DispatcherTimer]::new()
                $timer.Interval = [TimeSpan]::FromMilliseconds(200)
                $tagData.Timer = $timer

                $timer.Add_Tick({
                    $filterText     = $textBox.Text.Trim()
                    $sourceItems    = $tagData.SourceCollection

                    # Rebuild collection to filter (avoids delegate issues)
                    if ($null -ne $sourceItems) {
                        # Snapshot current selection to restore after the rebuild
                        $savedSelection = [System.Collections.Generic.HashSet[object]]::new()
                        foreach ($sel in $targetList.SelectedItems) { [void]$savedSelection.Add($sel) }

                        $filteredItems = [System.Collections.Generic.List[object]]::new()
                        
                        foreach ($item in $sourceItems) {
                            if ($null -eq $item) { continue }
                            
                            # Empty filter shows all
                            if ([string]::IsNullOrEmpty($filterText)) {
                                $filteredItems.Add($item)
                                continue
                            }
                            
                            # Get display text - try _DisplayText property first, then ToString
                            $displayText = $null
                            if ($item.PSObject) {
                                $prop = $item.PSObject.Properties['_DisplayText']
                                if ($prop) { $displayText = $prop.Value }
                            }
                            if (!$displayText) { $displayText = $item.ToString() }
                            
                            if ($displayText.IndexOf($filterText, [StringComparison]::OrdinalIgnoreCase) -ge 0) {
                                $filteredItems.Add($item)
                            }
                        }
                        
                        $newCollection = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
                        foreach ($item in $filteredItems) { [void]$newCollection.Add($item) }
                        
                        $newView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($newCollection)
                        $targetList.ItemsSource = $newView

                        # Restore selections that survived the filter
                        if ($savedSelection.Count -gt 0) {
                            foreach ($item in $filteredItems) {
                                if ($savedSelection.Contains($item)) {
                                    [void]$targetList.SelectedItems.Add($item)
                                }
                            }
                        }

                        if ($targetList.Tag -and $targetList.Tag.CountLabel) {
                            $label    = $targetList.Tag.CountLabel
                            $selected = $targetList.SelectedItems.Count
                            $total    = $targetList.Items.Count
                            $label.Text = "($selected/$total)"
                        }
                    }

                    $tagData.Timer.Stop()
                    $tagData.Timer = $null
                }.GetNewClosure())

                $timer.Start()
            }.GetNewClosure())
        }

        Set-FullWidthConstraint -Control $container -Parent $parent -FullWidth:$FullWidth

        # WPF properties go on the container so styling hits the whole unit, not just the listBox
        if ($WPFProperties) {
            Set-UiProperties -Control $container -Properties $WPFProperties
        }

        Write-Debug "Adding container with toolbar to parent"
        [void]$parent.Children.Add($container)

        # Fill sizes the container, not the inner listBox - the listBox's only siblings live inside the DockPanel, so the sibling math saw nothing and controls after the list got shoved off viewport. DockPanel LastChildFill hands the listBox the remainder.
        if ($Fill) { Set-UiFillParentHeight -Control $container }

        # EnabledWhen on the container so the toolbar and list disable together
        if ($EnabledWhen) {
            Register-UiCondition -TargetControl $container -Condition $EnabledWhen
        }
    }
    else {
        # Simple listbox without toolbar
        if (!$Fill) { $listBox.Height = $Height }
        $listBox.Margin = [System.Windows.Thickness]::new(4, 4, 4, 8)

        Set-FullWidthConstraint -Control $listBox -Parent $parent -FullWidth:$FullWidth

        if ($WPFProperties) {
            Set-UiProperties -Control $listBox -Properties $WPFProperties
        }

        Write-Debug "Adding simple ListBox to parent"
        [void]$parent.Children.Add($listBox)

        if ($Fill) { Set-UiFillParentHeight -Control $listBox }

        # EnabledWhen on the listbox itself
        if ($EnabledWhen) {
            Register-UiCondition -TargetControl $listBox -Condition $EnabledWhen
        }
    }

    # Register the ListBox control (not container) for value access
    Register-UiControlComplete -Name $Variable -Control $listBox
}