public/layout/New-UiTab.ps1

function New-UiTab {
    <#
    .SYNOPSIS
        Creates a tab with an optional header icon. Content scrolls if it overflows.
    .DESCRIPTION
        Tabs declared at the same level share one TabControl, so a window becomes tabbed just by
        listing New-UiTab blocks in its Content. -EnabledWhen gates a tab until another control
        or captured variable turns truthy (locking later tabs until a connection exists, say).
    .PARAMETER Header
        The text label displayed on the tab header.
    .PARAMETER Content
        ScriptBlock containing the tab's child controls.
    .PARAMETER EnabledWhen
        Control name, session variable name, or scriptblock. Truthy enables the tab, falsy
        disables it. Control references ('showAdvanced') and -Capture variables
        ('VCSAConnection') both work. A scriptblock re-evaluates whenever the controls it
        names change: { $serverName -and $environment } needs both. A scriptblock reads
        controls only, never -Capture variables.
    .PARAMETER Icon
        Optional icon name shown on the tab header. Use Show-UiGlyphBrowser to browse names.
    .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
        New-UiTab -Header "Settings" -EnabledWhen 'isConnected' -Content {
            New-UiInput -Label "Server" -Variable "server"
        }
 
        Creates a tab that is disabled until the 'isConnected' variable is truthy.
    .EXAMPLE
        # Tabs listed together share one TabControl. -Icon puts a glyph on the header
        New-UiTab -Header 'General' -Icon 'Settings' -Content {
            New-UiLabel -Text 'General settings'
        }
        New-UiTab -Header 'History' -Icon 'History' -Content {
            New-UiLabel -Text 'Run history'
        }
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Header,

        [Parameter(Mandatory)]
        [scriptblock]$Content,

        [Parameter()]
        [object]$EnabledWhen,

        [Parameter()]
        [hashtable]$WPFProperties
    )

    DynamicParam {
        Get-IconDynamicParameter -ParameterName 'Icon'
    }

    begin {
        $Icon = $PSBoundParameters['Icon']
    }

    process {

    $session = Assert-UiSession -CallerName 'New-UiTab'
    $parent  = $session.CurrentParent
    Write-Debug "Header: '$Header', Parent: $($parent.GetType().Name)"

    $targetTabControl = $null
    if ($parent -is [System.Windows.Controls.TabControl]) {
        $targetTabControl = $parent
    }
    elseif ($parent -is [System.Windows.Controls.Panel]) {
        foreach ($child in $parent.Children) {
            if ($child -is [System.Windows.Controls.TabControl]) {
                $targetTabControl = $child
                break
            }
        }
    }
    if (!$targetTabControl) {
        $colors = Get-ThemeColors
        $targetTabControl = [System.Windows.Controls.TabControl]@{
            Background      = [System.Windows.Media.Brushes]::Transparent
            BorderBrush     = ConvertTo-UiBrush $colors.Border
            BorderThickness = [System.Windows.Thickness]::new(0, 1, 0, 0)
            Padding         = [System.Windows.Thickness]::new(0)
            Margin          = [System.Windows.Thickness]::new(0, 0, 0, 10)
            TabStripPlacement = 'Top'
        }

        # Use WrapPanel instead of TabPanel to fix multi-row selection behavior
        Set-TabControlStyle -TabControl $targetTabControl

        # Apply center alignment to tab headers if requested
        if ($session.TabAlignment -eq 'Center') {
            # Need to modify the WrapPanel (which holds the tab headers) to center them
            $targetTabControl.Add_Loaded({
                param($sender, $eventArgs)

                # Find the WrapPanel (HeaderPanel) in the visual tree
                $headerPanel = $null
                $queue = [System.Collections.Generic.Queue[System.Windows.Media.Visual]]::new()
                $queue.Enqueue($sender)

                while ($queue.Count -gt 0) {
                    $current = $queue.Dequeue()

                    # Look for the WrapPanel with IsItemsHost, which the custom template puts there
                    if ($current -is [System.Windows.Controls.WrapPanel]) {
                        $headerPanel = $current
                        break
                    }
                    # The default template has a TabPanel there instead
                    if ($current -is [System.Windows.Controls.Primitives.TabPanel]) {
                        $headerPanel = $current
                        break
                    }

                    $childCount = [System.Windows.Media.VisualTreeHelper]::GetChildrenCount($current)
                    for ($i = 0; $i -lt $childCount; $i++) {
                        $child = [System.Windows.Media.VisualTreeHelper]::GetChild($current, $i)
                        $queue.Enqueue($child)
                    }
                }

                # Center the header panel if found
                if ($headerPanel) {
                    $headerPanel.HorizontalAlignment = 'Center'
                }
            })
        }

        Set-ResponsiveConstraints -Control $targetTabControl -FullWidth
        Add-UiControlToParent -Control $targetTabControl -Parent $parent
    }
    $tabItem = [System.Windows.Controls.TabItem]@{ Header = $Header }

    # HeaderTemplate, since a theme switch pins ControlForegroundBrush on any TextBlock without a TemplatedParent and the tab loses its selected colors
    $iconText = if ($Icon) { [PsUi.ModuleContext]::GetIcon($Icon) } else { $null }
    if ($iconText) {
        $textBlockType = [System.Windows.Controls.TextBlock]
        $centered      = [System.Windows.VerticalAlignment]::Center

        $iconFactory = [System.Windows.FrameworkElementFactory]::new($textBlockType)
        $iconFactory.SetValue($textBlockType::TextProperty, $iconText)
        $iconFactory.SetValue($textBlockType::FontFamilyProperty, [PsUi.ModuleContext]::ActiveIconFontFamily)
        $iconFactory.SetValue($textBlockType::FontSizeProperty, [double]12)
        $iconFactory.SetValue($textBlockType::VerticalAlignmentProperty, $centered)
        $iconFactory.SetValue($textBlockType::MarginProperty, [System.Windows.Thickness]::new(0, 0, 6, 0))

        $textFactory = [System.Windows.FrameworkElementFactory]::new($textBlockType)
        $textFactory.SetBinding($textBlockType::TextProperty, [System.Windows.Data.Binding]::new())
        $textFactory.SetValue($textBlockType::VerticalAlignmentProperty, $centered)

        $panelFactory = [System.Windows.FrameworkElementFactory]::new([System.Windows.Controls.StackPanel])
        $panelFactory.SetValue([System.Windows.Controls.StackPanel]::OrientationProperty, [System.Windows.Controls.Orientation]::Horizontal)
        $panelFactory.AppendChild($iconFactory)
        $panelFactory.AppendChild($textFactory)

        $headerTemplate            = [System.Windows.DataTemplate]::new()
        $headerTemplate.VisualTree = $panelFactory
        $tabItem.HeaderTemplate    = $headerTemplate
    }
    Set-TabItemStyle -TabItem $tabItem

    # Respect LayoutMode from session
    $layoutMode = if ($session.LayoutMode) { $session.LayoutMode } else { 'Responsive' }

    if ($layoutMode -eq 'Responsive') {
        # WrapPanel enables responsive horizontal wrapping based on available width
        $contentPanel = [System.Windows.Controls.WrapPanel]@{
            HorizontalAlignment = 'Stretch'
            Margin              = [System.Windows.Thickness]::new(10)
        }
    }
    else {
        # Use StackPanel for stack layout
        $contentPanel = [System.Windows.Controls.StackPanel]@{
            Orientation         = 'Vertical'
            HorizontalAlignment = 'Stretch'
            Margin              = [System.Windows.Thickness]::new(10)
        }
    }

    # Wrap tab content in ScrollViewer so overflow is scrollable.
    # In AutoSize mode the ScrollViewer reports its full content height (window grows), but if MaxHeight caps the window, the ScrollViewer constrains and scrolls.
    $tabScrollViewer = [System.Windows.Controls.ScrollViewer]@{
        VerticalScrollBarVisibility   = 'Auto'
        HorizontalScrollBarVisibility = 'Disabled'
        Focusable                     = $false
    }
    $tabScrollViewer.Content = $contentPanel

    # Once this one is at its end the wheel goes on to the outer ScrollViewer.
    $tabScrollViewer.Add_PreviewMouseWheel({
        param($sender, $eventArgs)
        trap { Write-Debug "Tab wheel routing: $_"; continue }

        $hit = $eventArgs.OriginalSource -as [System.Windows.DependencyObject]

        # A dropdown list or the column picker has in its own popup, but the preview wheel still comes through
        if ($hit -and ![object]::ReferenceEquals(
                [System.Windows.PresentationSource]::FromDependencyObject($hit),
                [System.Windows.PresentationSource]::FromDependencyObject($sender))) {
            return
        }

        # Set-UiWheelRouting leaves __WheelCapture on a control that keeps the wheel.
        while ($hit -and ![object]::ReferenceEquals($hit, $sender)) {
            if ($hit -is [System.Windows.FrameworkElement] -and $hit.Resources.Contains('__WheelCapture')) { return }
            # OriginalSource can be a ContentElement (WPF Run or a Hyperlink), VisualTreeHelper.GetParent throws on those, so hop to the tree until a Visual shows up
            $hit = if ($hit -is [System.Windows.Media.Visual] -or $hit -is [System.Windows.Media.Media3D.Visual3D]) {
                [System.Windows.Media.VisualTreeHelper]::GetParent($hit)
            }
            else { [System.Windows.LogicalTreeHelper]::GetParent($hit) }
        }

        $atTop    = ($eventArgs.Delta -gt 0) -and ($sender.VerticalOffset -eq 0)
        $atBottom = ($eventArgs.Delta -lt 0) -and ($sender.VerticalOffset -ge $sender.ScrollableHeight)
        $noScroll = $sender.ScrollableHeight -eq 0

        if ($atTop -or $atBottom -or $noScroll) {
            $eventArgs.Handled = $true
            $newArgs = [System.Windows.Input.MouseWheelEventArgs]::new(
                $eventArgs.MouseDevice, $eventArgs.Timestamp, $eventArgs.Delta)
            $newArgs.RoutedEvent = [System.Windows.UIElement]::MouseWheelEvent
            $parent = [System.Windows.Media.VisualTreeHelper]::GetParent($sender)
            if ($parent) { $parent.RaiseEvent($newArgs) }
        }
    })

    $tabItem.Content = $tabScrollViewer
    $oldParent = $session.CurrentParent
    $session.CurrentParent = $contentPanel
    $script:TabOrExpanderDepth = [int]$script:TabOrExpanderDepth + 1
    Write-Debug "Entering content block"

    # Restores stay out of a finally for 5.1, and the one pass loop soaks up a break or continue from -Content
    try {
        do { Invoke-UiContent -Content $Content -CallerName 'New-UiTab' -ErrorAction Stop } while ($false)
    }
    catch {
        # Restore parent before re-throwing
        $session.CurrentParent = $oldParent
        $script:TabOrExpanderDepth--
        throw
    }

    # Restore parent after successful content execution
    $session.CurrentParent = $oldParent
    $script:TabOrExpanderDepth--
    Write-Debug "Content block complete"

    # Tab content only sits in rows under a Responsive layout, which New-UiWindow has to set (but New-UiChildWindow gets by default)
    Set-UiRowAlignment -Panel $contentPanel

    # Apply custom WPF properties if specified
    if ($WPFProperties) { Set-UiProperties -Control $tabItem -Properties $WPFProperties }

    if ($EnabledWhen) { Register-UiCondition -TargetControl $tabItem -Condition $EnabledWhen }

    [void]$targetTabControl.Items.Add($tabItem)
    Write-Debug "Tab added to TabControl"
    }
}