Private/New-FilterPanel.ps1

#Requires -Version 5.1
<#
.SYNOPSIS
    Builds WPF filter controls at runtime and injects them into the filter WrapPanel.
 
.DESCRIPTION
    Accepts the output of Get-FilterableProperties and a reference to the parent
    WrapPanel in the UI. For each filterable property it creates the appropriate
    WPF control (CheckBox strip, multi-select ListBox, or TextBox), wires up
    change handlers that call Invoke-FilterUpdate, and appends the group to the
    WrapPanel.
 
    All WPF controls are instantiated directly as .NET objects (no XAML parsing
    at this stage) so the function can run on the UI thread without needing a
    separate XamlReader call.
 
    The $syncHash.FilterState hashtable is (re-)initialised by this function
    with all values selected by default for each property.
 
.PARAMETER FilterProperties
    Output from Get-FilterableProperties - array of property metadata objects.
 
.PARAMETER WrapPanel
    The System.Windows.Controls.WrapPanel that hosts the filter groups.
 
.PARAMETER SyncHash
    Shared synchronised hashtable. FilterState key is written here.
 
.PARAMETER OnChangeCallback
    A scriptblock to invoke whenever a filter control value changes.
    Typically: { Invoke-FilterUpdate -SyncHash $syncHash }
 
.NOTES
    TODO: Implement WPF control construction in Phase 4.
#>

function New-FilterPanel {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [PSObject[]]$FilterProperties,

        [Parameter(Mandatory)]
        [System.Windows.Controls.WrapPanel]$WrapPanel,

        [Parameter(Mandatory)]
        [System.Collections.Hashtable]$SyncHash,

        [Parameter(Mandatory)]
        [scriptblock]$OnChangeCallback
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    # Clear existing controls and reset filter state
    $WrapPanel.Children.Clear()
    $SyncHash.FilterState = @{}

    if ($FilterProperties.Count -eq 0) {
        $emptyText = [System.Windows.Controls.TextBlock]::new()
        $emptyText.Text = 'No filterable properties for this app result set.'
        $emptyText.SetResourceReference(
            [System.Windows.Controls.TextBlock]::ForegroundProperty, 'TextSecondaryBrush')
        $emptyText.Margin = [System.Windows.Thickness]::new(0, 4, 0, 0)
        [void]$WrapPanel.Children.Add($emptyText)
        return
    }

    foreach ($prop in $FilterProperties) {
        $propName = [string]$prop.Name
        $displayName = [string]$prop.DisplayName
        $controlType = [string]$prop.ControlType
        $uniqueValues = [string[]]$prop.UniqueValues

        # Initialise filter state - all values selected
        $SyncHash.FilterState[$propName] = [System.Collections.Generic.HashSet[string]]::new(
            $uniqueValues,
            [System.StringComparer]::OrdinalIgnoreCase
        )

        $groupBorder = [System.Windows.Controls.Border]::new()
        $groupBorder.BorderThickness = [System.Windows.Thickness]::new(1)
        $groupBorder.SetResourceReference(
            [System.Windows.Controls.Border]::BorderBrushProperty, 'ControlBorderBrush')
        $groupBorder.SetResourceReference(
            [System.Windows.Controls.Border]::BackgroundProperty, 'ControlBackgroundBrush')
        $groupBorder.CornerRadius = [System.Windows.CornerRadius]::new(4)
        $groupBorder.Margin = [System.Windows.Thickness]::new(0, 0, 10, 10)
        $groupBorder.Padding = [System.Windows.Thickness]::new(10, 8, 10, 10)
        $groupBorder.MinWidth = 200

        $groupPanel = [System.Windows.Controls.StackPanel]::new()

        $label = [System.Windows.Controls.TextBlock]::new()
        $label.Text = $displayName
        $label.FontWeight = [System.Windows.FontWeights]::SemiBold
        $label.SetResourceReference(
            [System.Windows.Controls.TextBlock]::ForegroundProperty, 'TextPrimaryBrush')
        $label.Margin = [System.Windows.Thickness]::new(0, 0, 0, 6)
        [void]$groupPanel.Children.Add($label)

        switch ($controlType) {
            'CheckBoxStrip' {
                $strip = [System.Windows.Controls.WrapPanel]::new()

                foreach ($value in $uniqueValues) {
                    $valueText = [string]$value

                    $checkbox = [System.Windows.Controls.CheckBox]::new()
                    $checkbox.Content = $valueText
                    $checkbox.IsChecked = $true
                    $checkbox.Margin = [System.Windows.Thickness]::new(0, 0, 10, 6)
                    $checkbox.Style = $SyncHash.Window.Resources['FluentCheckBox']

                    $checkbox.add_Checked({
                            [void]$SyncHash.FilterState[$propName].Add($valueText)
                            & $OnChangeCallback
                        }.GetNewClosure())

                    $checkbox.add_Unchecked({
                            [void]$SyncHash.FilterState[$propName].Remove($valueText)
                            & $OnChangeCallback
                        }.GetNewClosure())

                    [void]$strip.Children.Add($checkbox)
                }

                [void]$groupPanel.Children.Add($strip)
            }

            'MultiListBox' {
                $listBox = [System.Windows.Controls.ListBox]::new()
                $listBox.SelectionMode = [System.Windows.Controls.SelectionMode]::Multiple
                $listBox.MinWidth = 220
                $listBox.MaxHeight = 120
                $listBox.SetResourceReference(
                    [System.Windows.Controls.ListBox]::BorderBrushProperty, 'ControlBorderBrush')
                $listBox.SetResourceReference(
                    [System.Windows.Controls.ListBox]::BackgroundProperty, 'ControlBackgroundBrush')
                $listBox.SetResourceReference(
                    [System.Windows.Controls.ListBox]::ForegroundProperty, 'TextPrimaryBrush')

                foreach ($value in $uniqueValues) {
                    [void]$listBox.Items.Add([string]$value)
                }

                foreach ($item in $listBox.Items) {
                    [void]$listBox.SelectedItems.Add($item)
                }

                $listBox.add_SelectionChanged({
                        $selected = [System.Collections.Generic.HashSet[string]]::new(
                            [System.StringComparer]::OrdinalIgnoreCase
                        )
                        foreach ($item in $listBox.SelectedItems) {
                            [void]$selected.Add([string]$item)
                        }
                        $SyncHash.FilterState[$propName] = $selected
                        & $OnChangeCallback
                    }.GetNewClosure())

                [void]$groupPanel.Children.Add($listBox)
            }

            default {
                $textBox = [System.Windows.Controls.TextBox]::new()
                $textBox.MinWidth = 220
                $textBox.ToolTip = 'Type to filter values (contains match). Leave blank to include all values.'
                $textBox.Style = $SyncHash.Window.Resources['FluentTextBox']

                $allValues = [string[]]$uniqueValues

                $textBox.add_TextChanged({
                        $needle = $textBox.Text

                        $selected = [System.Collections.Generic.HashSet[string]]::new(
                            [System.StringComparer]::OrdinalIgnoreCase
                        )

                        foreach ($candidate in $allValues) {
                            if ([string]::IsNullOrWhiteSpace($needle) -or
                                $candidate.IndexOf($needle, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) {
                                [void]$selected.Add($candidate)
                            }
                        }

                        $SyncHash.FilterState[$propName] = $selected
                        & $OnChangeCallback
                    }.GetNewClosure())

                [void]$groupPanel.Children.Add($textBox)
            }
        }

        $groupBorder.Child = $groupPanel
        [void]$WrapPanel.Children.Add($groupBorder)
    }
}