public/controls/inputs/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: Blocks whitespace on typing and paste. Pair with -ValidatePattern for the real format check. - 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 when given the input value as $args[0]. Return $true if valid, $false or throw if not. -ErrorMessage sets the message shown. Without it, an error the block wrote before failing shows in the field. .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 "Invalid input" for -Validate or "Doesn't 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. One of None, FilePicker, FolderPicker, AdvancedFolderPicker, ComputerPicker, UserPicker, GroupPicker, UserGroupPicker, or OUPicker. .PARAMETER HelperOptions Hashtable that is inevitably splatted into the underlying Show-* picker at click time. Any param the picker takes is fair game. String values get looked up against registered control variables first; on a match, the live control value wins. Otherwise the string is a literal. Credential controls unwrap to [PSCredential] automatically. Common keys: Server, Credential, Root (OUPicker); InitialDirectory, Filter, Title (File/Folder pickers). Pass the control name as a string ('dcServer'), not the bareword ($dcServer). Variable hydration only fires inside -Action blocks, so $dcServer is $null at the moment this hashtable is built. .PARAMETER WPFProperties Hashtable of additional WPF properties to set on the control. Allows setting any valid WPF property not explicitly exposed as a parameter. Bad values warn and get skipped. A property name that does not exist on the control is skipped silently (-Verbose shows it). Nothing stops execution. Supports attached properties using dot notation (e.g., "Grid.Row"). .EXAMPLE # The common flavors in one form New-UiInput -Label 'Name' -Variable 'name' -Placeholder 'Patricia' New-UiInput -Label 'Age' -Variable 'age' -InputType Int New-UiInput -Label 'Hourly rate' -Variable 'rate' -InputType Double New-UiInput -Label 'Password' -Variable 'pass' -Secure New-UiInput -Label 'Ticket' -Variable 'ticket' -Default 'CHG0042' -ReadOnly .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 .EXAMPLE New-UiInput -Label "Server" -Variable "dcServer" New-UiCredential -Label "Directory credential" -Variable "dirCreds" New-UiInput -Label "OU" -Variable "targetOU" -HelperButton OUPicker -HelperOptions @{ Server = 'dcServer'; Credential = 'dirCreds' } # Picker reads both controls at click time. A Server without a Credential entry pops a credential dialog first. #> [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', 'OUPicker')] [string]$HelperButton = 'None', [hashtable]$HelperOptions, [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" $ownerSessionId = $session.SessionId $pushSession = ${function:Push-UiSession} $popSession = ${function:Pop-UiSession} $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') } 'OUPicker' { [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...' } 'OUPicker' { 'Select organizational unit...' } } $iconBlock = [System.Windows.Controls.TextBlock]::new() $iconBlock.Text = $iconCode $iconBlock.FontFamily = [PsUi.ModuleContext]::ActiveIconFontFamily $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 HelperOptions = $HelperOptions } # Click handler hands off to the shared dispatcher. Mode + Options is all it needs. $helperBtn.Add_Click({ param($sender, $eventArgs) # Covers Show-UiHelperError throwing from inside the catch block trap { Write-Debug "New-UiInput helper button: $_"; continue } $info = $sender.Tag $sessionToken = & $pushSession -SessionId $ownerSessionId try { $result = Invoke-UiHelperPicker -Mode $info.Mode -Options $info.HelperOptions if ($result) { $info.TextBox.Text = $result } } catch { Show-UiHelperError -ErrorRecord $_ -Mode $info.Mode } & $popSession -Token $sessionToken }.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 } # Hook 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 SessionId = $ownerSessionId PushSession = $pushSession PopSession = $popSession InvokeCallback = ${function:Invoke-UiCallback} } # 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) { $sessionToken = & $ctx.PushSession -SessionId $ctx.SessionId $written = [System.Collections.Generic.List[object]]::new() try { $result = & $ctx.InvokeCallback -ScriptBlock $ctx.Validate -ArgumentList (, $inputValue) -ErrorList $written # Treat any falsy value (including $null, 0, empty string) as validation failure if (!$result) { $isValid = $false } } catch { $isValid = $false $errorMessage = $_.Exception.Message } & $ctx.PopSession -Token $sessionToken # The written error beats Invalid input and -ErrorMessage beats both $needsText = !$isValid -and !$errorMessage -and !$ctx.ErrorMessage if ($needsText -and $written.Count) { $errorMessage = "$($written[0])" } elseif ($isValid) { foreach ($record in $written) { Write-Warning "New-UiInput Validate error: $record" } } } # 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 } } # Pick the validation event by 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 # Hook conditional enabling if specified if ($EnabledWhen) { Register-UiCondition -TargetControl $inputControl -Condition $EnabledWhen -ClearIfDisabled:$ClearIfDisabled } # Enter triggers the 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]::GetSession($ownerSessionId) 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()) } } |