public/list/New-UiList.ps1
|
function New-UiList { <# .SYNOPSIS Creates a selectable list of items with optional filtering and selection controls. .DESCRIPTION Builds a themed ListBox with optional real-time text filtering, select-all/none buttons, and 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. .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]$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' } # Bubble scroll events to parent ScrollViewer so list doesn't swallow them $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 (needed before filtering setup) # When filtering is enabled, we MUST use a collection with CollectionView $sourceCollection = $null if ($null -ne $ItemsSource) { Write-Debug "Binding external ItemsSource collection" $sourceCollection = $ItemsSource # If it's an AsyncObservableCollection, update dispatcher 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 } # Build the container - either a simple wrapper or one with toolbar if ($needsToolbar) { # Create outer container with DockPanel layout $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 = [System.Windows.Media.FontFamily]::new('Segoe MDL2 Assets') 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) # Container grid holds the textbox and clear button $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 = [System.Windows.Media.FontFamily]::new('Segoe MDL2 Assets') 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 = [System.Windows.Media.FontFamily]::new('Segoe MDL2 Assets') 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) # Wire up the add button - shows input dialog and adds to 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) # Update count label if present if ($addState.CountLabel) { $selected = $addState.ListView.SelectedItems.Count $total = $addState.ListView.Items.Count $addState.CountLabel.Text = "($selected/$total)" } } }.GetNewClosure()) } [void]$container.Children.Add($toolbar) $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 # Show/hide clear button 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 $displayBinding = $targetList.DisplayMemberPath # Rebuild collection to filter (avoids delegate issues) if ($null -ne $sourceItems) { # Snapshot current selection so we can restore after 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) } } # Create new view from filtered items $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) } } } # Update selection count after filter 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 # Apply custom WPF properties to container (user may want to style the whole unit) if ($WPFProperties) { Set-UiProperties -Control $container -Properties $WPFProperties } Write-Debug "Adding container with toolbar to parent" [void]$parent.Children.Add($container) # Wire up EnabledWhen on the container (disables toolbar and list together) if ($EnabledWhen) { Register-UiCondition -TargetControl $container -Condition $EnabledWhen } } else { # Simple listbox without toolbar $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) # Wire up 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 } |