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 to bind as the list's data source, for lists that change at runtime.
        Anything that isn't already a PsUi threadsafe collection gets wrapped in one, and the
        variable is repointed at the wrap so $list.Add() from a background action keeps working.
        The wrap is no longer the type passed in, so .AddRange(), .Sort() and -is [ArrayList]
        stop working. A [ref] has its .Value repointed instead. -NoBind skips the repoint.
    .PARAMETER NoBind
        Skip the repoint. The list still binds the wrap, the original keeps its own identity,
        and the two only stay in step while the mirror holds.
    .PARAMETER DisplayFormat
        Format string for displaying objects. Use property names in braces.
        Example: "{Username} ({AccountType})" shows "wesley (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.
        The new item comes up selected. With -MultiSelect it joins the current selection.
    .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 viewport instead of the fixed -Height.
        List resizes with the window. Use when the list is the dominant content in the view.
    .PARAMETER CaptureScrollWheel
        Keep every mouse-wheel event inside the list, ends included. Same as -ScrollWheel Capture.
    .PARAMETER ScrollWheel
        Says who gets the wheel while the cursor is over the list. Page, the default, hands every
        wheel event to the page, so a window full of them still scrolls. Edge scrolls the list's
        own rows until it reaches the top or bottom and gives the page the wheel from there.
        Capture holds on at the ends as well, so the page stays put while the cursor is here.
        A -Fill list starts on Edge instead, since it holds the viewport and the page behind it
        has almost no scroll of its own left. Pass -ScrollWheel to override that.
    .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 = 'wesley'; FullName = 'Wesley'; AccountType = 'Admin' }
    #>

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

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

        # Untyped so the [ref] reaches Resolve-UiListSource whole.
        [Parameter()]
        $ItemsSource,

        [switch]$NoBind,

        [Parameter()]
        [string]$DisplayFormat,

        [switch]$MultiSelect,

        [switch]$Filterable,

        [switch]$SelectionControls,

        [switch]$AllowAdd,

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

        [int]$Height = 150,

        [switch]$Fill,

        [switch]$CaptureScrollWheel,

        [switch]$FullWidth,

        [Parameter()]
        [object]$EnabledWhen,

        [Parameter()]
        [hashtable]$WPFProperties,

        [ValidateSet('Page', 'Edge', 'Capture')]
        [string]$ScrollWheel = 'Page'
    )

    # An empty -ItemsSource collection evaluates as false and still means "bind to this one".
    if ($Items.Count -gt 0 -and $PSBoundParameters.ContainsKey('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' }

    $wheelMode = if ($CaptureScrollWheel) { 'Capture' } else { $ScrollWheel }

    if ($Fill -and $wheelMode -eq 'Page' -and !$PSBoundParameters.ContainsKey('ScrollWheel')) {
        $wheelMode = 'Edge'
    }
    Set-UiWheelRouting -Control $listBox -Mode $wheelMode

    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.
    # Also before the toolbar, since the filter box and + button close over $sourceCollection by value.
    $sourceCollection = $null
    $mirrorAttached   = $false
    if ($PSBoundParameters.ContainsKey('ItemsSource')) {
        Write-Debug "Binding external ItemsSource collection"
        $resolved         = Resolve-UiListSource -Source $ItemsSource -NoBind:$NoBind
        $sourceCollection = $resolved.Collection
        $mirrorAttached   = $resolved.MirrorAttached

        if ($resolved.NeedsBind -and !$NoBind -and $resolved.Repointed.Count -eq 0 -and $resolved.Converted.Count -eq 0) {
            Write-Warning 'New-UiList -ItemsSource: could not repoint any script variable to the autowrapped collection. Use a variable declared before New-UiWindow (or inside -Content) so $list.Add() and Add-UiListItem stay connected to the list.'
        }

        if ($resolved.RefWriteMissed) {
            Write-Warning 'New-UiList -ItemsSource: only a [ref] to a variable can be repointed, so a [ref] built from a property still holds the original list. Add through the [ref] .Value, or pass an ObservableCollection, which the list tracks wherever it lives.'
        }

        if ($resolved.Converted.Count -gt 0) {
            Write-Warning "New-UiList -ItemsSource: a type constrained target converted the threadsafe collection on assignment, and the copy never reaches the list ($($resolved.Converted -join ', ')). Drop the type, or use -NoBind and drive the list with Add-UiListItem."
        }

        $session.RegisterListCollection($Variable, $sourceCollection)
    }
    elseif ($Items.Count -gt 0) {
        # Seeded straight into the threadsafe collection. A plain ObservableCollection here made background Add-UiListItem throw the cross thread CollectionView error. The view attaches below and pins the whole thing to this thread.
        Write-Debug "Seeding $($Items.Count) static items into an AsyncObservableCollection"
        $sourceCollection = [PsUi.AsyncObservableCollection[object]]::new($Items)

        # 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)
    }

    # Every control on this source shares the default view, so filtering that one empties the other lists and dropdowns too
    if ($null -ne $sourceCollection) {
        $collectionView = [System.Windows.Data.ListCollectionView]::new($sourceCollection)

        # Selector follows CurrentItem on any view but the default one and that starts on row 0. Leaving this null preselects the first item
        $listBox.IsSynchronizedWithCurrentItem = $false
        $listBox.ItemsSource                   = $collectionView
    }

    # With a toolbar the whole thing goes in a DockPanel, and without one the ListBox goes in 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)

            # Refs for the TextChanged handler, including the Timer key. The filter drives View, SourceCollection keeps the unfiltered items.
            $filterBox.Tag = @{
                ClearButton      = $clearBtn
                ListView         = $listBox
                Timer            = $null
                SourceCollection = $sourceCollection
                View             = $collectionView
            }

            [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
                SessionId   = $session.SessionId
                PushSession = ${function:Push-UiSession}
                PopSession  = ${function:Pop-UiSession}
            }
            $addBtn.Add_Click({
                trap { Write-Debug "New-UiList add button: $_"; continue }

                # Still $null if the dialog throws, which skips the add below
                $result       = $null
                $sessionToken = & $addState.PushSession -SessionId $addState.SessionId
                $result       = Show-UiInputDialog -Title 'Add Item' -Prompt $addState.PromptText
                & $addState.PopSession -Token $sessionToken
                if ([string]::IsNullOrWhiteSpace($result)) { return }

                # Top level because an ObservableCollection[int] source refuses 'abc' and the trap's continue would skip the rest of an if
                [void]$addState.Collection.Add($result)

                $listView = $addState.ListView
                $last     = $listView.Items.Count - 1
                $isLast   = $last -ge 0 -and $listView.Items[$last] -ceq $result

                # SelectedItems.Add ignores the dialog's '8080' string when the source holds 8080 as Int32
                if ($isLast) { $result = $listView.Items[$last] }

                # SelectedItem picks the first equal item, the older copy of a repeated name, and the index only holds while the new row is the last one showing
                if ($listView.SelectionMode -ne 'Single') { [void]$listView.SelectedItems.Add($result) }
                elseif ($isLast) { $listView.SelectedIndex = $last }
                else { $listView.SelectedItem = $result }

                if ($addState.CountLabel) {
                    $selected = $listView.SelectedItems.Count
                    $total    = $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()
                    $view       = $tagData.View

                    # Filter the view, do NOT swap ItemsSource. Building a throwaway collection per keystroke leaves the ListBox attached to that throwaway forever, and every later Add-UiListItem then lands in the registered collection where the list never sees it.
                    if ($null -ne $view) {
                        $needle = $filterText
                        if ([string]::IsNullOrEmpty($needle)) {
                            $view.Filter = $null
                        }
                        else {
                            # [Predicate[object]] cast is required in 5.1: a bare scriptblock won't bind.
                            $view.Filter = [Predicate[object]]{
                                param($item)
                                if ($null -eq $item) { return $false }
                                $displayText = $null
                                if ($item.PSObject) {
                                    $prop = $item.PSObject.Properties['_DisplayText']
                                    if ($prop) { $displayText = $prop.Value }
                                }
                                if (!$displayText) { $displayText = $item.ToString() }
                                return $displayText.IndexOf($needle, [StringComparison]::OrdinalIgnoreCase) -ge 0
                            }.GetNewClosure()
                        }
                        $view.Refresh()

                        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
        }
    }

    if ($mirrorAttached) {
        Register-UiCollectionCleanup -Control $listBox -Collection $sourceCollection
    }

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