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