Skip to content

Instantly share code, notes, and snippets.

@alekrutkowski
Last active June 11, 2026 18:17
Show Gist options
  • Select an option

  • Save alekrutkowski/7674a60d4a13dcb3efe66b1018fd66fb to your computer and use it in GitHub Desktop.

Select an option

Save alekrutkowski/7674a60d4a13dcb3efe66b1018fd66fb to your computer and use it in GitHub Desktop.
Show-SelectizeDialog PowerShell Function – inspired by selectize.js
# For usage instructions scroll down
function Show-SelectizeDialog {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[object[]] $Items,
[string] $DisplayProperty,
[string] $ValueProperty,
[string] $Title = 'Select items',
[string] $Prompt = 'Search and select one or more items...',
[bool] $DarkMode = $false,
[ValidateRange(420, 4000)]
[int] $Width = 560,
[ValidateRange(360, 4000)]
[int] $Height = 520
)
if ([Threading.Thread]::CurrentThread.GetApartmentState() -ne 'STA') {
throw @'
WPF requires an STA thread.
Run PowerShell using one of these commands:
powershell.exe -STA
pwsh.exe -STA
'@
}
Add-Type -AssemblyName PresentationFramework
Add-Type -AssemblyName PresentationCore
Add-Type -AssemblyName WindowsBase
# Convert the supplied values into consistent internal objects.
$index = 0
$wrappedItems = @(
foreach ($item in $Items) {
if ($DisplayProperty) {
$property = $item.PSObject.Properties[$DisplayProperty]
if ($null -eq $property) {
throw "An item does not contain display property '$DisplayProperty'."
}
$display = [string] $property.Value
}
else {
$display = [string] $item
}
if ($ValueProperty) {
$property = $item.PSObject.Properties[$ValueProperty]
if ($null -eq $property) {
throw "An item does not contain value property '$ValueProperty'."
}
$value = $property.Value
}
else {
$value = $item
}
[pscustomobject] @{
Index = $index
Display = $display
Value = $value
Original = $item
}
$index++
}
)
# Centralized light and dark color palettes.
$theme = if ($DarkMode) {
[pscustomobject] @{
WindowBackground = '#202020'
Surface = '#2B2B2B'
SurfaceAlternate = '#323232'
Border = '#4A4A4A'
BorderStrong = '#707070'
Text = '#F5F5F5'
MutedText = '#B5B5B5'
Accent = '#60CDFF'
AccentText = '#000000'
ChipBackground = '#334A5E'
ChipBorder = '#5BA6D6'
ChipText = '#F5F5F5'
Hover = '#3A3A3A'
Selection = '#3D566A'
SelectionText = '#FFFFFF'
ButtonBackground = '#323232'
ButtonBorder = '#5A5A5A'
}
}
else {
[pscustomobject] @{
WindowBackground = '#F5F5F5'
Surface = '#FFFFFF'
SurfaceAlternate = '#F8F8F8'
Border = '#D0D0D0'
BorderStrong = '#A0A0A0'
Text = '#1F1F1F'
MutedText = '#707070'
Accent = '#0067C0'
AccentText = '#FFFFFF'
ChipBackground = '#E4F0FA'
ChipBorder = '#8AB8DF'
ChipText = '#1F1F1F'
Hover = '#EEEEEE'
Selection = '#D6E9F8'
SelectionText = '#1F1F1F'
ButtonBackground = '#FFFFFF'
ButtonBorder = '#C7C7C7'
}
}
$escapedTitle = [Security.SecurityElement]::Escape($Title)
$escapedPrompt = [Security.SecurityElement]::Escape($Prompt)
[xml] $xaml = @"
<Window
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="$escapedTitle"
Width="$Width"
Height="$Height"
MinWidth="420"
MinHeight="360"
WindowStartupLocation="CenterScreen"
ResizeMode="CanResize"
Background="$($theme.WindowBackground)"
Foreground="$($theme.Text)"
FontFamily="Segoe UI Variable Text">
<Window.Resources>
<Style TargetType="Button">
<Setter Property="Padding" Value="16,7"/>
<Setter Property="Margin" Value="6,0,0,0"/>
<Setter Property="MinWidth" Value="85"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Background" Value="$($theme.ButtonBackground)"/>
<Setter Property="Foreground" Value="$($theme.Text)"/>
<Setter Property="BorderBrush" Value="$($theme.ButtonBorder)"/>
</Style>
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="10,8"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Foreground" Value="$($theme.Text)"/>
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="$($theme.Hover)"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="$($theme.Selection)"/>
<Setter Property="Foreground" Value="$($theme.SelectionText)"/>
</Trigger>
</Style.Triggers>
</Style>
</Window.Resources>
<Grid Margin="18">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Combined selection accumulator and filter -->
<Border
x:Name="InputBorder"
Grid.Row="0"
Margin="0,0,0,10"
MinHeight="48"
MaxHeight="130"
Background="$($theme.Surface)"
BorderBrush="$($theme.BorderStrong)"
BorderThickness="1"
CornerRadius="7">
<ScrollViewer
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<WrapPanel
x:Name="InputPanel"
Margin="7">
<Grid
x:Name="SearchContainer"
MinWidth="210"
Margin="2">
<TextBox
x:Name="SearchBox"
MinWidth="200"
Padding="5,6"
BorderThickness="0"
Background="Transparent"
Foreground="$($theme.Text)"
CaretBrush="$($theme.Text)"
FontSize="14"/>
<TextBlock
x:Name="SearchPlaceholder"
Margin="6,0,0,0"
VerticalAlignment="Center"
Foreground="$($theme.MutedText)"
IsHitTestVisible="False"
Text="$escapedPrompt"/>
</Grid>
</WrapPanel>
</ScrollViewer>
</Border>
<!-- Filtered choices -->
<Border
Grid.Row="1"
Background="$($theme.Surface)"
BorderBrush="$($theme.Border)"
BorderThickness="1"
CornerRadius="7">
<ListBox
x:Name="ChoicesList"
BorderThickness="0"
Background="$($theme.Surface)"
Foreground="$($theme.Text)"
DisplayMemberPath="Display"
SelectionMode="Single"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"/>
</Border>
<!-- Dialog buttons -->
<StackPanel
Grid.Row="2"
Margin="0,14,0,0"
HorizontalAlignment="Right"
Orientation="Horizontal">
<Button
x:Name="CancelButton"
Content="Cancel"
IsCancel="True"/>
<Button
x:Name="OkButton"
Content="OK"
IsDefault="True"
Background="$($theme.Accent)"
Foreground="$($theme.AccentText)"
BorderBrush="$($theme.Accent)"/>
</StackPanel>
</Grid>
</Window>
"@
$reader = [System.Xml.XmlNodeReader]::new($xaml)
try {
$window = [Windows.Markup.XamlReader]::Load($reader)
}
finally {
$reader.Close()
}
$inputBorder = $window.FindName('InputBorder')
$inputPanel = $window.FindName('InputPanel')
$searchContainer = $window.FindName('SearchContainer')
$searchBox = $window.FindName('SearchBox')
$searchPlaceholder = $window.FindName('SearchPlaceholder')
$choicesList = $window.FindName('ChoicesList')
$okButton = $window.FindName('OkButton')
$cancelButton = $window.FindName('CancelButton')
$state = [pscustomobject] @{
Window = $window
InputBorder = $inputBorder
InputPanel = $inputPanel
SearchContainer = $searchContainer
SearchBox = $searchBox
SearchPlaceholder = $searchPlaceholder
ChoicesList = $choicesList
Items = $wrappedItems
SelectedItems = [System.Collections.Generic.List[object]]::new()
Theme = $theme
Accepted = $false
RefreshChoices = $null
RefreshInput = $null
AddCurrentItem = $null
RemoveItem = $null
}
$refreshChoices = {
param(
[Parameter(Mandatory)]
[object] $State
)
$query = $State.SearchBox.Text.Trim()
$selectedIndexes = @(
$State.SelectedItems |
ForEach-Object { $_.Index }
)
$filteredItems = @(
$State.Items |
Where-Object {
$notSelected = $_.Index -notin $selectedIndexes
$matchesFilter =
[string]::IsNullOrWhiteSpace($query) -or
$_.Display.IndexOf(
$query,
[StringComparison]::CurrentCultureIgnoreCase
) -ge 0
$notSelected -and $matchesFilter
}
)
$State.ChoicesList.ItemsSource = $null
$State.ChoicesList.ItemsSource = $filteredItems
if ($filteredItems.Count -gt 0) {
$State.ChoicesList.SelectedIndex = 0
}
else {
$State.ChoicesList.SelectedIndex = -1
}
}
$removeItem = {
param(
[Parameter(Mandatory)]
[object] $State,
[Parameter(Mandatory)]
[object] $Entry
)
[void] $State.SelectedItems.Remove($Entry)
& $State.RefreshInput $State
& $State.RefreshChoices $State
[void] $State.SearchBox.Focus()
}
$refreshInput = {
param(
[Parameter(Mandatory)]
[object] $State
)
$searchHadFocus = $State.SearchBox.IsKeyboardFocused
$State.InputPanel.Children.Clear()
foreach ($entry in $State.SelectedItems) {
$chip = [Windows.Controls.Border]::new()
$chip.Background = (
[Windows.Media.BrushConverter]::new().ConvertFromString(
$State.Theme.ChipBackground
)
)
$chip.BorderBrush = (
[Windows.Media.BrushConverter]::new().ConvertFromString(
$State.Theme.ChipBorder
)
)
$chip.BorderThickness = [Windows.Thickness]::new(1)
$chip.CornerRadius = [Windows.CornerRadius]::new(12)
$chip.Padding = [Windows.Thickness]::new(9, 3, 4, 3)
$chip.Margin = [Windows.Thickness]::new(0, 0, 6, 5)
$chip.Cursor = 'Arrow'
$chip.Focusable = $true
$chip.ToolTip = 'Click to focus; press Backspace or Delete to remove'
$chipContent = [Windows.Controls.StackPanel]::new()
$chipContent.Orientation = 'Horizontal'
$chipText = [Windows.Controls.TextBlock]::new()
$chipText.Text = $entry.Display
$chipText.VerticalAlignment = 'Center'
$chipText.Margin = [Windows.Thickness]::new(1, 0, 2, 0)
$chipText.Foreground = (
[Windows.Media.BrushConverter]::new().ConvertFromString(
$State.Theme.ChipText
)
)
$removeButton = [Windows.Controls.Button]::new()
$removeButton.Content = [char] 0x00D7
$removeButton.ToolTip = "Remove $($entry.Display)"
$removeButton.MinWidth = 21
$removeButton.MinHeight = 20
$removeButton.Padding = [Windows.Thickness]::new(2, 0, 2, 0)
$removeButton.Margin = [Windows.Thickness]::new(5, 0, 0, 0)
$removeButton.BorderThickness = [Windows.Thickness]::new(0)
$removeButton.Background = [Windows.Media.Brushes]::Transparent
$removeButton.Cursor = 'Hand'
$removeButton.Focusable = $false
$removeButton.Foreground = (
[Windows.Media.BrushConverter]::new().ConvertFromString(
$State.Theme.ChipText
)
)
$chipContext = [pscustomobject] @{
State = $State
Entry = $entry
}
$chip.Tag = $chipContext
$removeButton.Tag = $chipContext
$chip.Add_MouseLeftButtonDown({
param($sender, $eventArgs)
# Do not consume clicks intended for the remove button.
$element = $eventArgs.OriginalSource
while ($null -ne $element -and $element -ne $sender) {
if ($element -is [Windows.Controls.Button]) {
return
}
try {
$element = [Windows.Media.VisualTreeHelper]::GetParent(
$element
)
}
catch {
$element = $null
}
}
[void] $sender.Focus()
$eventArgs.Handled = $true
})
$chip.Add_GotKeyboardFocus({
param($sender, $eventArgs)
$context = $sender.Tag
if ($null -eq $context) {
return
}
$sender.BorderBrush = (
[Windows.Media.BrushConverter]::new().ConvertFromString(
$context.State.Theme.Accent
)
)
$sender.BorderThickness = [Windows.Thickness]::new(2)
})
$chip.Add_LostKeyboardFocus({
param($sender, $eventArgs)
$context = $sender.Tag
if ($null -eq $context) {
return
}
$sender.BorderBrush = (
[Windows.Media.BrushConverter]::new().ConvertFromString(
$context.State.Theme.ChipBorder
)
)
$sender.BorderThickness = [Windows.Thickness]::new(1)
})
$chip.Add_PreviewKeyDown({
param($sender, $eventArgs)
if (
$eventArgs.Key -ne 'Back' -and
$eventArgs.Key -ne 'Delete'
) {
return
}
$context = $sender.Tag
if (
$null -eq $context -or
$null -eq $context.State -or
$null -eq $context.Entry
) {
return
}
$eventArgs.Handled = $true
& $context.State.RemoveItem `
$context.State `
$context.Entry
})
$removeButton.Add_Click({
param($sender, $eventArgs)
$context = $sender.Tag
if (
$null -eq $context -or
$null -eq $context.State -or
$null -eq $context.Entry
) {
return
}
& $context.State.RemoveItem `
$context.State `
$context.Entry
})
[void] $chipContent.Children.Add($chipText)
[void] $chipContent.Children.Add($removeButton)
$chip.Child = $chipContent
[void] $State.InputPanel.Children.Add($chip)
}
[void] $State.InputPanel.Children.Add($State.SearchContainer)
$State.SearchPlaceholder.Visibility = if (
$State.SearchBox.Text.Length -gt 0 -or
$State.SelectedItems.Count -gt 0
) {
'Collapsed'
}
else {
'Visible'
}
if ($searchHadFocus) {
[void] $State.SearchBox.Focus()
}
}
$addCurrentItem = {
param(
[Parameter(Mandatory)]
[object] $State
)
$entry = $State.ChoicesList.SelectedItem
if ($null -eq $entry) {
return
}
[void] $State.SelectedItems.Add($entry)
$State.SearchBox.Clear()
& $State.RefreshInput $State
& $State.RefreshChoices $State
[void] $State.SearchBox.Focus()
}
$state.RefreshChoices = $refreshChoices
$state.RefreshInput = $refreshInput
$state.AddCurrentItem = $addCurrentItem
$state.RemoveItem = $removeItem
$window.Tag = $state
$inputBorder.Tag = $state
$searchBox.Tag = $state
$choicesList.Tag = $state
$okButton.Tag = $state
$cancelButton.Tag = $state
$inputBorder.Add_MouseLeftButtonDown({
param($sender, $eventArgs)
$dialogState = $sender.Tag
if ($null -ne $dialogState) {
[void] $dialogState.SearchBox.Focus()
}
})
$searchBox.Add_GotKeyboardFocus({
param($sender, $eventArgs)
$dialogState = $sender.Tag
if ($null -eq $dialogState) {
return
}
$dialogState.InputBorder.BorderBrush = (
[Windows.Media.BrushConverter]::new().ConvertFromString(
$dialogState.Theme.Accent
)
)
$dialogState.InputBorder.BorderThickness = (
[Windows.Thickness]::new(2)
)
})
$searchBox.Add_LostKeyboardFocus({
param($sender, $eventArgs)
$dialogState = $sender.Tag
if ($null -eq $dialogState) {
return
}
$dialogState.InputBorder.BorderBrush = (
[Windows.Media.BrushConverter]::new().ConvertFromString(
$dialogState.Theme.BorderStrong
)
)
$dialogState.InputBorder.BorderThickness = (
[Windows.Thickness]::new(1)
)
})
$searchBox.Add_TextChanged({
param($sender, $eventArgs)
$dialogState = $sender.Tag
if ($null -eq $dialogState) {
return
}
$dialogState.SearchPlaceholder.Visibility = if (
$sender.Text.Length -gt 0 -or
$dialogState.SelectedItems.Count -gt 0
) {
'Collapsed'
}
else {
'Visible'
}
& $dialogState.RefreshChoices $dialogState
})
$searchBox.Add_PreviewKeyDown({
param($sender, $eventArgs)
$dialogState = $sender.Tag
if ($null -eq $dialogState) {
return
}
switch ($eventArgs.Key) {
'Enter' {
& $dialogState.AddCurrentItem $dialogState
$eventArgs.Handled = $true
}
'Down' {
if ($dialogState.ChoicesList.Items.Count -gt 0) {
[void] $dialogState.ChoicesList.Focus()
$dialogState.ChoicesList.SelectedIndex = 0
}
$eventArgs.Handled = $true
}
'Back' {
if (
$sender.Text.Length -eq 0 -and
$dialogState.SelectedItems.Count -gt 0
) {
$lastEntry = $dialogState.SelectedItems[
$dialogState.SelectedItems.Count - 1
]
& $dialogState.RemoveItem `
$dialogState `
$lastEntry
$eventArgs.Handled = $true
}
}
'Delete' {
if (
$sender.Text.Length -eq 0 -and
$dialogState.SelectedItems.Count -gt 0
) {
$lastEntry = $dialogState.SelectedItems[
$dialogState.SelectedItems.Count - 1
]
& $dialogState.RemoveItem `
$dialogState `
$lastEntry
$eventArgs.Handled = $true
}
}
'Left' {
if (
$sender.Text.Length -eq 0 -and
$dialogState.InputPanel.Children.Count -gt 1
) {
$lastChipIndex =
$dialogState.InputPanel.Children.Count - 2
$lastChip =
$dialogState.InputPanel.Children[$lastChipIndex]
if ($lastChip.Focusable) {
[void] $lastChip.Focus()
$eventArgs.Handled = $true
}
}
}
'Escape' {
$dialogState.Window.Close()
$eventArgs.Handled = $true
}
}
})
$choicesList.Add_MouseDoubleClick({
param($sender, $eventArgs)
$dialogState = $sender.Tag
if ($null -ne $dialogState) {
& $dialogState.AddCurrentItem $dialogState
}
})
$choicesList.Add_PreviewKeyDown({
param($sender, $eventArgs)
$dialogState = $sender.Tag
if ($null -eq $dialogState) {
return
}
switch ($eventArgs.Key) {
'Enter' {
& $dialogState.AddCurrentItem $dialogState
$eventArgs.Handled = $true
}
'Space' {
& $dialogState.AddCurrentItem $dialogState
$eventArgs.Handled = $true
}
'Escape' {
[void] $dialogState.SearchBox.Focus()
$eventArgs.Handled = $true
}
}
})
$okButton.Add_Click({
param($sender, $eventArgs)
$dialogState = $sender.Tag
if ($null -eq $dialogState) {
return
}
$dialogState.Accepted = $true
$dialogState.Window.DialogResult = $true
})
$cancelButton.Add_Click({
param($sender, $eventArgs)
$dialogState = $sender.Tag
if ($null -ne $dialogState) {
$dialogState.Window.DialogResult = $false
}
})
$window.Add_ContentRendered({
param($sender, $eventArgs)
$dialogState = $sender.Tag
if ($null -eq $dialogState) {
return
}
& $dialogState.RefreshInput $dialogState
& $dialogState.RefreshChoices $dialogState
[void] $dialogState.SearchBox.Focus()
})
[void] $window.ShowDialog()
if ($state.Accepted) {
$result = @(
$state.SelectedItems |
ForEach-Object { $_.Value }
)
Write-Output -NoEnumerate $result
}
else {
Write-Output -NoEnumerate @()
}
}

Show-SelectizeDialog returns an array of the selected values. The simplest usage is:

$result = Show-SelectizeDialog -Items <array>

Example 1: Select from a list of strings

$fruits = @(
    'Apple'
    'Banana'
    'Orange'
    'Pear'
    'Strawberry'
)

$selected = Show-SelectizeDialog `
    -Items $fruits `
    -Title 'Choose fruits' `
    -Prompt 'Type to filter fruits...'

$selected

If the user selects Apple and Pear, $selected will contain:

Apple
Pear

Example 2: Select from objects and return IDs

Suppose you have objects with Id and Name properties:

$users = @(
    [pscustomobject]@{ Id = 101; Name = 'Alice' }
    [pscustomobject]@{ Id = 102; Name = 'Bob' }
    [pscustomobject]@{ Id = 103; Name = 'Charlie' }
)

$selectedIds = Show-SelectizeDialog `
    -Items $users `
    -DisplayProperty Name `
    -ValueProperty Id `
    -Title 'Select users' `
    -Prompt 'Search by name...' `
    -DarkMode $true

$selectedIds

The dialog will show:

Alice
Bob
Charlie

but if the user selects Alice and Charlie, the function returns:

101
103

Loading the function first

If the function is stored in Show-SelectizeDialog.ps1, load it into the current session with:

. .\Show-SelectizeDialog.ps1

Then call it as shown above.

Note that the function requires PowerShell running in STA mode:

pwsh.exe -STA

or

powershell.exe -STA
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment