public/controls/New-UiInput.ps1
|
function New-UiInput { <# .SYNOPSIS Creates a labeled text input field. .DESCRIPTION Creates a TextBox or PasswordBox with a label above it. When -Secure or -Password is used, input is masked and the hydrated variable contains a SecureString instead of plain text. .PARAMETER Label Label shown above the input. .PARAMETER Variable Variable name to store the value. .PARAMETER Default Initial value. .PARAMETER InputType Type of input validation to apply. Restricts character entry based on type: - String: No restrictions (default) - Int: Only digits and optional leading minus sign - Double: Digits, single decimal point, and optional leading minus sign - Email: Standard text (validation on blur/submit recommended) - Phone: Digits, spaces, dashes, parentheses, and plus sign - Alphanumeric: Only letters and numbers - Path: Valid file path characters .PARAMETER Password Mask input as password. Hydrated variable contains SecureString. By default, includes a peek button (eye icon) to reveal password while held. .PARAMETER Secure Alias for -Password. Mask input; hydrated variable contains SecureString. .PARAMETER NoPeek Hide the peek button on password fields. By default, password fields show an eye icon that reveals the password while held. Use this to disable it. Only valid with -Password or -Secure. .PARAMETER Required Mark the field as required with an asterisk. .PARAMETER Validate ScriptBlock for custom validation. Receives the input value as $args[0]. Return $true if valid, $false or throw to indicate invalid. Used with -ErrorMessage to show a custom error message. .PARAMETER ValidatePattern Regex pattern the input must match. Shows error if input doesn't match. For simple pattern validation, prefer this over -Validate. .PARAMETER ErrorMessage Custom error message shown when validation fails. Defaults to "Input is invalid" for -Validate or "Input does not match required format" for -ValidatePattern. .PARAMETER ValidateOnChange Validate on each keystroke instead of only when focus leaves the control. Can feel aggressive; use sparingly for fields needing immediate feedback. .PARAMETER Placeholder Placeholder/watermark text shown when textbox is empty. .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, clears the input value when the control becomes disabled. By default, values are preserved when disabled. .PARAMETER ReadOnly Makes the input read-only. Users can select and copy text but not edit it. Useful for displaying status or computed values that can be updated via Set-UiValue. .PARAMETER SubmitButton Name of a registered button to trigger when Enter is pressed in this input. The button must be created with -Variable to register it for lookup. Works with both New-UiButton and New-UiActionCard buttons. .PARAMETER FullWidth Stretches the control to fill available width instead of fixed sizing. .PARAMETER HelperButton Adds a picker button next to the input. Supports FilePicker, FolderPicker, ComputerPicker, UserPicker, GroupPicker, etc. .PARAMETER WPFProperties Hashtable of additional WPF properties to set on the control. Allows setting any valid WPF property not explicitly exposed as a parameter. Invalid properties will generate warnings but not stop execution. Supports attached properties using dot notation (e.g., "Grid.Row"). .EXAMPLE New-UiInput -Label "Age" -Variable "userAge" -InputType Int # Only allows integer input .EXAMPLE New-UiInput -Label "Price" -Variable "itemPrice" -InputType Double # Allows decimal numbers .EXAMPLE New-UiInput -Label "Password" -Variable "userPassword" -Secure # Password field with peek button; $userPassword contains SecureString .EXAMPLE New-UiInput -Label "Password" -Variable "userPassword" -Password -NoPeek # Password field without peek button .EXAMPLE New-UiInput -Label "Search" -Variable "searchTerm" -SubmitButton "searchBtn" New-UiButton -Text "Search" -Variable "searchBtn" -Action { Write-Host "Searching for $searchTerm" } # Pressing Enter in the input triggers the Search button .EXAMPLE New-UiInput -Label "Email" -Variable "userEmail" -ValidatePattern '^[\w.+-]+@[\w.-]+\.\w+$' -ErrorMessage 'Enter a valid email address' # Shows red border and error text if email format is wrong .EXAMPLE New-UiInput -Label "Port" -Variable "portNum" -InputType Int -Validate { param($val) [int]$val -ge 1 -and [int]$val -le 65535 } -ErrorMessage 'Port must be 1-65535' # Custom validation with scriptblock #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Label, [Parameter(Mandatory)] [string]$Variable, [string]$Default, [ValidateSet('String', 'Int', 'Double', 'Email', 'Phone', 'Alphanumeric', 'Path')] [string]$InputType = 'String', [switch]$Password, [switch]$Secure, [switch]$NoPeek, [switch]$Required, [scriptblock]$Validate, [string]$ValidatePattern, [string]$ErrorMessage, [switch]$ValidateOnChange, [string]$Placeholder, [switch]$FullWidth, [ValidateSet('None', 'FilePicker', 'FolderPicker', 'AdvancedFolderPicker', 'ComputerPicker', 'UserPicker', 'GroupPicker', 'UserGroupPicker')] [string]$HelperButton = 'None', [Parameter()] [object]$EnabledWhen, [switch]$ClearIfDisabled, [switch]$ReadOnly, [Parameter()] [string]$SubmitButton, [Parameter()] [hashtable]$WPFProperties ) # Treat -Secure same as -Password for control creation $isSecure = $Password -or $Secure # -NoPeek only makes sense for password fields if ($NoPeek -and !$isSecure) { throw "-NoPeek can only be used with -Password or -Secure" } $session = Assert-UiSession -CallerName 'New-UiInput' Write-Debug "Label='$Label', Variable='$Variable', InputType='$InputType', Secure=$isSecure" $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) } # Label row - contains label on left, error message on right $labelRow = [System.Windows.Controls.DockPanel]@{ Margin = [System.Windows.Thickness]::new(0, 0, 0, 4) LastChildFill = $false } $labelText = if ($Required) { "$Label *" } else { $Label } $labelBlock = [System.Windows.Controls.TextBlock]@{ Text = $labelText FontSize = 12 Foreground = ConvertTo-UiBrush $colors.ControlFg Tag = 'ControlFgBrush' } [PsUi.ThemeEngine]::RegisterElement($labelBlock) [System.Windows.Controls.DockPanel]::SetDock($labelBlock, 'Left') [void]$labelRow.Children.Add($labelBlock) # Error text sits to the right of the label (hidden until validation fails) $errorText = [System.Windows.Controls.TextBlock]@{ Foreground = ConvertTo-UiBrush $colors.Error FontSize = 11 FontStyle = 'Italic' Visibility = 'Hidden' Margin = [System.Windows.Thickness]::new(8, 0, 0, 0) Tag = 'ErrorBrush' } [PsUi.ThemeEngine]::RegisterElement($errorText) [System.Windows.Controls.DockPanel]::SetDock($errorText, 'Right') [void]$labelRow.Children.Add($errorText) [void]$stack.Children.Add($labelRow) if ($isSecure) { # Use the shared password input helper $peekResult = New-PasswordInputWithPeek -DefaultValue $Default -NoPeek:$NoPeek -Height 28 $inputControl = $peekResult.PasswordBox $inputContainer = $peekResult.Container } else { # Create TextBox via ControlFactory (handles placeholder natively) $inputControl = [PsUi.ControlFactory]::CreateTextBox($Placeholder) $inputControl.Text = $Default # Set up context menu and theme (each TextBox needs its own instance) Set-TextBoxStyle -TextBox $inputControl # Apply input type filtering (character-level restriction) if ($InputType -ne 'String') { Set-TextBoxInputFilter -TextBox $inputControl -InputType $InputType } # Apply read-only mode if requested if ($ReadOnly) { $inputControl.IsReadOnly = $true } # For TextBox, use input directly as container $inputContainer = $inputControl } # Override: Explicit sizing for consistent input appearance across themes $inputControl.Height = 28 $inputControl.FontSize = 12 $inputControl.FontFamily = [System.Windows.Media.FontFamily]::new('Segoe UI') $inputControl.Padding = [System.Windows.Thickness]::new(2, 0, 2, 0) # Add helper button if requested (TextBox only, not secure inputs) if ($HelperButton -ne 'None' -and !$isSecure) { # Create wrapper grid: [Input][Button] $wrapperGrid = [System.Windows.Controls.Grid]::new() # Input column (stretch) $col1 = [System.Windows.Controls.ColumnDefinition]::new() $col1.Width = [System.Windows.GridLength]::new(1, [System.Windows.GridUnitType]::Star) [void]$wrapperGrid.ColumnDefinitions.Add($col1) # Button column (auto) $col2 = [System.Windows.Controls.ColumnDefinition]::new() $col2.Width = [System.Windows.GridLength]::Auto [void]$wrapperGrid.ColumnDefinitions.Add($col2) # Add input container to first column [System.Windows.Controls.Grid]::SetColumn($inputContainer, 0) [void]$wrapperGrid.Children.Add($inputContainer) # Create helper button $helperBtn = [System.Windows.Controls.Button]::new() $helperBtn.Width = 28 $helperBtn.Height = 28 $helperBtn.Margin = [System.Windows.Thickness]::new(4, 0, 0, 0) $helperBtn.Padding = [System.Windows.Thickness]::new(0) $helperBtn.Cursor = [System.Windows.Input.Cursors]::Hand # Set icon and tooltip based on helper type $iconCode = switch ($HelperButton) { 'FilePicker' { [PsUi.ModuleContext]::GetIcon('OpenFile') } 'FolderPicker' { [PsUi.ModuleContext]::GetIcon('Folder') } 'AdvancedFolderPicker' { [PsUi.ModuleContext]::GetIcon('FolderOpen') } 'ComputerPicker' { [PsUi.ModuleContext]::GetIcon('Desktop') } 'UserPicker' { [PsUi.ModuleContext]::GetIcon('Contact') } 'GroupPicker' { [PsUi.ModuleContext]::GetIcon('People') } 'UserGroupPicker' { [PsUi.ModuleContext]::GetIcon('People') } } $helperBtn.ToolTip = switch ($HelperButton) { 'FilePicker' { 'Browse for file...' } 'FolderPicker' { 'Browse for folder...' } 'AdvancedFolderPicker' { 'Browse for folder...' } 'ComputerPicker' { 'Select computer...' } 'UserPicker' { 'Select user...' } 'GroupPicker' { 'Select group...' } 'UserGroupPicker' { 'Select user or group...' } } $iconBlock = [System.Windows.Controls.TextBlock]::new() $iconBlock.Text = $iconCode $iconBlock.FontFamily = [System.Windows.Media.FontFamily]::new('Segoe MDL2 Assets') $iconBlock.FontSize = 14 $iconBlock.HorizontalAlignment = 'Center' $iconBlock.VerticalAlignment = 'Center' $helperBtn.Content = $iconBlock Set-ButtonStyle -Button $helperBtn # Sync enabled state with input $enabledBinding = [System.Windows.Data.Binding]::new('IsEnabled') $enabledBinding.Source = $inputControl [void]$helperBtn.SetBinding([System.Windows.UIElement]::IsEnabledProperty, $enabledBinding) # Store info for click handler $helperBtn.Tag = @{ Mode = $HelperButton TextBox = $inputControl } # Click handler $helperBtn.Add_Click({ param($sender, $eventArgs) $info = $sender.Tag $result = $null try { switch ($info.Mode) { 'FilePicker' { $result = Show-UiPathPicker -Mode 'File' } 'FolderPicker' { $result = Show-UiFolderPicker -Simple } 'AdvancedFolderPicker' { $result = Show-UiFolderPicker } 'ComputerPicker' { $picked = Show-WindowsObjectPicker -ObjectType Computer; if ($picked) { $result = $picked.RawValue } } 'UserPicker' { $picked = Show-WindowsObjectPicker -ObjectType User; if ($picked) { $result = $picked.RawValue } } 'GroupPicker' { $picked = Show-WindowsObjectPicker -ObjectType Group; if ($picked) { $result = $picked.RawValue } } 'UserGroupPicker' { $picked = Show-WindowsObjectPicker -ObjectType User, Group; if ($picked) { $result = $picked.RawValue } } } if ($result) { $info.TextBox.Text = $result } } catch { Write-Warning "Helper button error: $_" } }.GetNewClosure()) [System.Windows.Controls.Grid]::SetColumn($helperBtn, 1) [void]$wrapperGrid.Children.Add($helperBtn) [void]$stack.Children.Add($wrapperGrid) $controlElement = $wrapperGrid } else { [void]$stack.Children.Add($inputContainer) $controlElement = $inputContainer } # Wire up validation if configured $hasValidation = $Validate -or $ValidatePattern if ($hasValidation) { # Pre-compute brushes to avoid repeated conversions in event handlers $borderBrush = ConvertTo-UiBrush $colors.Border $errorBrush = ConvertTo-UiBrush $colors.Error # Build context for validation handlers $validationContext = @{ Input = $inputControl ErrorText = $errorText Validate = $Validate ValidatePattern = $ValidatePattern ErrorMessage = $ErrorMessage BorderBrush = $borderBrush ErrorBrush = $errorBrush IsSecure = $isSecure } # Validation runner - checks input and updates UI $runValidation = { param($ctx) $inputValue = if ($ctx.IsSecure) { $ctx.Input.Password } else { $ctx.Input.Text } # Skip validation on empty values (use -Required for emptiness check) if ([string]::IsNullOrEmpty($inputValue)) { $ctx.ErrorText.Visibility = 'Hidden' $ctx.Input.BorderBrush = $ctx.BorderBrush return } $isValid = $true $errorMessage = $null # Run scriptblock validation if ($ctx.Validate) { try { $result = & $ctx.Validate $inputValue # Treat any falsy value (including $null, 0, empty string) as validation failure if (!$result) { $isValid = $false } } catch { $isValid = $false $errorMessage = $_.Exception.Message } } # Run pattern validation if ($isValid -and $ctx.ValidatePattern) { if ($inputValue -notmatch $ctx.ValidatePattern) { $isValid = $false } } # Update UI based on validation result if ($isValid) { $ctx.ErrorText.Visibility = 'Hidden' $ctx.Input.BorderBrush = $ctx.BorderBrush } else { # Figure out what message to show $msg = $errorMessage if (!$msg) { if ($ctx.ErrorMessage) { $msg = $ctx.ErrorMessage } elseif ($ctx.ValidatePattern) { $msg = "Doesn't match required format" } else { $msg = 'Invalid input' } } $ctx.ErrorText.Text = $msg $ctx.ErrorText.Visibility = 'Visible' $ctx.Input.BorderBrush = $ctx.ErrorBrush } } # Wire up validation event based on mode if ($ValidateOnChange) { # Validate on every keystroke if ($isSecure) { $inputControl.Add_PasswordChanged({ param($sender, $eventArgs) & $runValidation $validationContext }.GetNewClosure()) } else { $inputControl.Add_TextChanged({ param($sender, $eventArgs) & $runValidation $validationContext }.GetNewClosure()) } } else { # Validate when focus leaves the control $inputControl.Add_LostFocus({ param($sender, $eventArgs) & $runValidation $validationContext }.GetNewClosure()) } # Clear error state when user starts typing (provides immediate feedback that we noticed) if (!$ValidateOnChange) { if ($isSecure) { $inputControl.Add_PasswordChanged({ param($sender, $eventArgs) $validationContext.ErrorText.Visibility = 'Hidden' $validationContext.Input.BorderBrush = $validationContext.BorderBrush }.GetNewClosure()) } else { $inputControl.Add_TextChanged({ param($sender, $eventArgs) $validationContext.ErrorText.Visibility = 'Hidden' $validationContext.Input.BorderBrush = $validationContext.BorderBrush }.GetNewClosure()) } } } # Tag wrapper for FormLayout unwrapping in New-UiGrid Set-UiFormControlTag -Wrapper $stack -Label $labelBlock -Control $controlElement # 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) # Register control in all session registries (with theme support for TextBox) $isTextBox = $inputControl -is [System.Windows.Controls.TextBox] # Get initial value - PasswordBox uses .Password, TextBox uses .Text $initialValue = if ($isSecure) { $null } else { $inputControl.Text } Register-UiControlComplete -Name $Variable -Control $inputControl -InitialValue $initialValue -RegisterTheme:$isTextBox # Wire up conditional enabling if specified if ($EnabledWhen) { Register-UiCondition -TargetControl $inputControl -Condition $EnabledWhen -ClearIfDisabled:$ClearIfDisabled } # Wire up Enter key to trigger submit button if ($SubmitButton) { $btnName = $SubmitButton $inputControl.Add_KeyDown({ param($sender, $keyArgs) if ($keyArgs.Key -eq [System.Windows.Input.Key]::Return) { # Only trigger if input has actual content $inputValue = if ($sender -is [System.Windows.Controls.PasswordBox]) { $sender.Password } else { $sender.Text } if ([string]::IsNullOrWhiteSpace($inputValue)) { return } $sess = [PsUi.SessionManager]::Current if (!$sess) { return } # Look up registered button and trigger its click $btn = $sess.GetRegisteredButton($btnName) if ($btn -and $btn.IsEnabled) { $btn.RaiseEvent([System.Windows.RoutedEventArgs]::new([System.Windows.Controls.Primitives.ButtonBase]::ClickEvent)) $keyArgs.Handled = $true } } }.GetNewClosure()) } } |