public/controls/inputs/New-UiDropdown.ps1

function New-UiDropdown {
    <#
    .SYNOPSIS
        Creates a dropdown selection control.
    .DESCRIPTION
        Creates a labeled ComboBox for selecting from a list of items.
    .PARAMETER Label
        Label text displayed above the dropdown.
    .PARAMETER Variable
        Variable name to store selection.
    .PARAMETER Items
        Array of selectable items. Mutually exclusive with ItemsSource.
    .PARAMETER Default
        Initially selected item. Matched against the choices without regard to source, whether
        they came from -Items or a prefilled -ItemsSource. A source that fills after the window
        is built stays unselected, since selecting mid feed would fire -OnChange and flip
        -EnabledWhen while the feed is still running.
    .PARAMETER CaptureScrollWheel
        Same as -ScrollWheel Capture.
    .PARAMETER ScrollWheel
        Says what gets the scrollwheel while the cursor is over a closed dropdown. Page, the default,
        scrolls the page under the cursor. Capture steps the selection instead, whether or not
        the dropdown has been clicked. An open dropdown list scrolls itself either way.
    .PARAMETER FullWidth
        Forces the control to take full width in WrapPanel layouts.
    .PARAMETER EnabledWhen
        Conditional enabling based on another control's state. Accepts either:
        - A control proxy (e.g., $toggleControl) - enables when that control is truthy
        - A scriptblock (e.g., { $toggle -and $userName }) - enables when expression is true
 
        Truthy values: CheckBox=checked, TextBox=non-empty, ComboBox=has selection.
    .PARAMETER ClearIfDisabled
        When used with -EnabledWhen, resets the dropdown selection when it becomes disabled.
    .PARAMETER OnChange
        ScriptBlock to execute when the selection changes. Receives the new
        selection value as the first parameter.
    .PARAMETER WPFProperties
        Hashtable of additional WPF properties to set on the control.
        Allows setting any valid WPF property not explicitly exposed as a parameter.
        Bad values warn and get skipped. A property name that does not exist on the control is
        skipped silently (-Verbose shows it). Nothing stops execution.
        Supports attached properties using dot notation (e.g., "Grid.Row").
    .PARAMETER ItemsSource
        A collection to show as the choices, for choices 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 $choices.Add() from a background action shows up. The wrap is
        no longer the type passed in, so .AddRange(), .Sort() and -is [ArrayList] stop working.
        A [ref] has its .Value repointed instead. Items display through ToString(), so keep them
        strings. -NoBind skips the repoint.
    .PARAMETER NoBind
        Skip the repoint. The dropdown still shows the wrap, the original keeps its own
        identity, and the two only stay in step while the mirror holds.
    .EXAMPLE
        New-UiDropdown -Label "Color" -Variable "color" -Items @('Red','Green','Blue') -WPFProperties @{ ToolTip = "Pick a color" }
    .EXAMPLE
        # Choices that fill in later. $fruits is the threadsafe list after the call.
        $fruits = [System.Collections.Generic.List[object]]::new()
        New-UiDropdown -Label "Fruit" -Variable "fruit" -ItemsSource $fruits
        New-UiButton -Text "Load" -Action { 'Apple', 'Pear' | ForEach-Object { $fruits.Add($_) } }
    .EXAMPLE
        New-UiDropdown -Label "Environment" -Variable "env" -Items @('Dev','Staging','Prod') -OnChange {
            param($selected)
            Write-Host "Switched to: $selected"
        }
    #>

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

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

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

        [string]$Default,

        [switch]$CaptureScrollWheel,

        [switch]$FullWidth,

        [Parameter()]
        [object]$EnabledWhen,

        [Parameter()]
        [switch]$ClearIfDisabled,

        [scriptblock]$OnChange,

        [Parameter()]
        [hashtable]$WPFProperties,

        [Parameter()]
        $ItemsSource,

        [switch]$NoBind,

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

    # ContainsKey and not truthiness, since an empty -ItemsSource collection evals as false and still means "show this one".
    if ($Items.Count -gt 0 -and $PSBoundParameters.ContainsKey('ItemsSource')) {
        throw "New-UiDropdown cannot use both -Items and -ItemsSource. Choose one."
    }

    try {
        $session = Assert-UiSession -CallerName 'New-UiDropdown'
        Write-Debug "Label='$Label', Variable='$Variable', Items=$($Items.Count)"

        $colors  = Get-ThemeColors
        $parent  = $session.CurrentParent
        Write-Debug "Parent: $($parent.GetType().Name)"

        $stack = [System.Windows.Controls.StackPanel]@{
            Margin = [System.Windows.Thickness]::new(4, 4, 4, 8)
        }

        $labelBlock = [System.Windows.Controls.TextBlock]@{
            Text       = $Label
            FontSize   = 12
            Foreground = ConvertTo-UiBrush $colors.ControlFg
            Margin     = [System.Windows.Thickness]::new(0, 0, 0, 4)
            Tag        = 'ControlFgBrush'
        }
        [PsUi.ThemeEngine]::RegisterElement($labelBlock)
        [void]$stack.Children.Add($labelBlock)

        $combo = [System.Windows.Controls.ComboBox]@{
            Height = 28
        }

        # Before Set-ComboBoxStyle, which hands the wheel to the page on any combo not already routed.
        $wheelMode = if ($CaptureScrollWheel) { 'Capture' } else { $ScrollWheel }
        Set-UiWheelRouting -Control $combo -Mode $wheelMode
        Set-ComboBoxStyle -ComboBox $combo

        # AsyncObservableCollection for the background Add-UiListItem case.
        $collection     = $null
        $mirrorAttached = $false
        if ($PSBoundParameters.ContainsKey('ItemsSource')) {
            $resolved       = Resolve-UiListSource -Source $ItemsSource -NoBind:$NoBind
            $collection     = $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-UiDropdown -ItemsSource: could not repoint any script variable to the autowrapped collection. Use a variable declared before New-UiWindow (or inside -Content) so $choices.Add() and Add-UiListItem stay connected to the dropdown.'
            }

            if ($resolved.RefWriteMissed) {
                Write-Warning 'New-UiDropdown -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 dropdown tracks wherever it lives.'
            }

            if ($resolved.Converted.Count -gt 0) {
                Write-Warning "New-UiDropdown -ItemsSource: a type constrained target converted the threadsafe collection on assignment, and the copy never reaches the dropdown ($($resolved.Converted -join ', ')). Drop the type, or use -NoBind and drive the dropdown with Add-UiListItem."
            }
        }
        elseif ($Items.Count -gt 0) { $collection = [PsUi.AsyncObservableCollection[object]]::new($Items) }
        else {
            # ::new($null) can't pick between the collection and Dispatcher overloads.
            $collection = [PsUi.AsyncObservableCollection[object]]::new()
        }
        $combo.ItemsSource = $collection
        $session.RegisterListCollection($Variable, $collection)

        # -eq ignores case and SelectedItem doesn't, so -Default 'west' matches 'West' here and then fails to select it.
        # Sources that fill later stay unselected, since selecting mid feed fires -OnChange and flips -EnabledWhen while the loop runs.
        $defaultItem = $null
        if ($Default) {
            foreach ($item in $collection) {
                # Quoted, or an item that is itself a list turns -eq into a filter over that list.
                if ("$item" -eq $Default) { $defaultItem = $item; break }
            }
        }

        if ($null -ne $defaultItem) { $combo.SelectedItem = $defaultItem }
        elseif ($collection.Count -gt 0) { $combo.SelectedIndex = 0 }

        [void]$stack.Children.Add($combo)

        # Tag wrapper for FormLayout unwrapping in New-UiGrid
        Set-UiFormControlTag -Wrapper $stack -Label $labelBlock -Control $combo

        # FullWidth in WrapPanel contexts
        Set-FullWidthConstraint -Control $stack -Parent $parent -FullWidth:$FullWidth

        # Apply custom WPF properties if specified
        if ($WPFProperties) {
            Set-UiProperties -Control $stack -Properties $WPFProperties
        }

        Write-Debug "Adding to $($parent.GetType().Name)"
        [void]$parent.Children.Add($stack)

        # Hung on the combo (New-UiGrid -FormLayout unwraps the stack out from under it).
        if ($mirrorAttached) { Register-UiCollectionCleanup -Control $combo -Collection $collection }

        # Register control in all session registries
        Register-UiControlComplete -Name $Variable -Control $combo -InitialValue $combo.SelectedItem

        # Store the OnChange callback in Tag so the event handler can reach it
        if ($OnChange) {
            if (!$combo.Tag -or $combo.Tag -isnot [hashtable]) { $combo.Tag = @{} }
            $combo.Tag['OnChange']  = $OnChange
            $combo.Tag['SessionId'] = $session.SessionId

            $combo.Add_SelectionChanged({
                param($sender, $e)
                $tag = $sender.Tag
                if (!$tag -or !$tag.OnChange) { return }

                $selectedValue = $sender.SelectedItem
                $sessionToken  = Push-UiSession -SessionId $tag.SessionId
                $callback      = @{
                    ScriptBlock  = $tag.OnChange
                    ArgumentList = (, $selectedValue)
                    Label        = 'OnChange callback'
                }
                try { $null = Invoke-UiCallback @callback }
                catch { Write-Warning "OnChange callback error: $_" }
                Pop-UiSession -Token $sessionToken
            })
        }

        # Hook conditional enabling if specified
        if ($EnabledWhen) {
            Register-UiCondition -TargetControl $combo -Condition $EnabledWhen -ClearIfDisabled:$ClearIfDisabled
        }
    }
    catch {
        Write-Debug "ERROR: $($_.Exception.Message)"
        Write-Debug "STACK: $($_.ScriptStackTrace)"
        throw
    }
}